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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,355 changes: 1,355 additions & 0 deletions config/reference.php

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,15 @@ services:
tags:
- { name: serializer.normalizer, priority: 1000 }

App\Process\EnvironmentProvider:
arguments:
$composerHome: "%composer.home%"


App\Process\ProcessFactory:
arguments:
$rootPath: "%kernel.project_dir%"
$composerHome: "%composer.home%"
$envProvider: '@App\Process\EnvironmentProvider'

App\Service\SatisManager:
public: true
Expand Down
2 changes: 1 addition & 1 deletion src/Event/BuildEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use App\DTO\RepositoryInterface;
use Symfony\Contracts\EventDispatcher\Event;

final class BuildEvent extends Event
class BuildEvent extends Event
{
public const string NAME = 'satis_build';

Expand Down
33 changes: 33 additions & 0 deletions src/Process/EnvironmentProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Process;

class EnvironmentProvider
{
public function __construct(
private readonly string $composerHome,
) {
}

public function getEnv(): array
{
$env = [];

foreach ($_SERVER as $key => $value) {
if (\is_string($value) && false !== $envValue = \getenv($key)) {
$env[$key] = $envValue;
}
}

foreach ($_ENV as $key => $value) {
if (\is_string($value)) {
$env[$key] = $value;
}
}

$env['COMPOSER_HOME'] ??= $this->composerHome;
$env['COMPOSER_NO_INTERACTION'] = '1';

return $env;
}
}
49 changes: 15 additions & 34 deletions src/Process/ProcessFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,28 @@

use Symfony\Component\Process\Process;

class ProcessFactory
final readonly class ProcessFactory
{
protected string $rootPath;

protected string $composerHome;

public function __construct(string $rootPath, string $composerHome)
{
$this->rootPath = $rootPath;
$this->composerHome = $composerHome;
public function __construct(
private string $rootPath,
private EnvironmentProvider $envProvider,
) {
}

public function create(array $command, ?int $timeout = null): Process
{
$exec = \reset($command);
$command[\key($command)] = $this->rootPath . '/' . $exec;

return new Process($command, $this->rootPath, $this->getEnv(), null, $timeout);
}

protected function getEnv(): array
{
$env = [];

foreach ($_SERVER as $k => $v) {
if (\is_string($v) && false !== $v = \getenv($k)) {
$env[$k] = $v;
}
if (empty($command)) {
throw new \InvalidArgumentException('Command array cannot be empty.');
}

foreach ($_ENV as $k => $v) {
if (\is_string($v)) {
$env[$k] = $v;
}
}

if (empty($env['COMPOSER_HOME'])) {
$env['COMPOSER_HOME'] = $this->composerHome;
}
$env['COMPOSER_NO_INTERACTION'] = 1;
$command[0] = $this->rootPath . '/' . $command[0];

return $env;
return new Process(
command: $command,
cwd: $this->rootPath,
env: $this->envProvider->getEnv(),
input: null,
timeout: $timeout
);
}
}
31 changes: 29 additions & 2 deletions src/Service/SatisManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use Symfony\Component\Process\Exception\RuntimeException;

#[AsEventListener(event: BuildEvent::class, method: 'onBuild', priority: 100)]
final class SatisManager
class SatisManager
{
protected string $satisFilename;

Expand Down Expand Up @@ -62,6 +62,19 @@ public function run(): \Generator
}
}

/**
* Handle a BuildEvent by executing the Satis build for the event's repository.
*
* Acquires the build lock, runs the generated Satis process, releases the lock,
* and updates the event's status with the process exit code (`1` if an internal
* RuntimeException occurs).
*
* @param BuildEvent $event the build event; its repository (if any) is used to
* determine the build target and its status will be
* updated with the process exit code
*
* @throws \JsonException
*/
public function onBuild(BuildEvent $event): void
{
$repository = $event->getRepository();
Expand All @@ -80,6 +93,20 @@ public function onBuild(BuildEvent $event): void
$event->setStatus($status);
}

/**
* Build the command argument array for executing a Satis build.
*
* Constructs a command array based on the configured satis file and output directory,
* optionally scoping the build to a single repository and adding extra options or arguments.
*
* @param string|null $repositoryName name of a single repository to target, or `null` to include all repositories
* @param array $options associative or list-style Satis options to include (added via the builder's options API)
* @param array $extraArgs additional positional arguments to append to the command
*
* @throws \JsonException if encoding the built command to JSON for logging fails
*
* @return array the constructed command as an array of command and arguments suitable for Process execution
*/
protected function getCommandLine(?string $repositoryName = null, array $options = [], array $extraArgs = []): array
{
$configuration = $this->manager->getConfig();
Expand All @@ -99,7 +126,7 @@ protected function getCommandLine(?string $repositoryName = null, array $options
$satisCommandBuilder->withRepository($repositoryName);
}

$this->logger->info(\json_encode($satisCommandBuilder->build()));
$this->logger->info(\json_encode($satisCommandBuilder->build(), \JSON_THROW_ON_ERROR));

return $satisCommandBuilder->build();
}
Expand Down
67 changes: 67 additions & 0 deletions tests/Process/EnvironmentProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace App\Tests\Process;

use App\Process\EnvironmentProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;

class EnvironmentProviderTest extends KernelTestCase
{
protected function tearDown(): void
{
\putenv('TEST_KEY');
unset($_SERVER['TEST_KEY'], $_ENV['TEST_ENV_KEY']);
}
Comment on lines +11 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing cleanup for COMPOSER_HOME in tearDown.

testComposerHomeNotOverwrittenIfProvidedInEnv sets COMPOSER_HOME via putenv() and $_SERVER (lines 57-58), but tearDown() doesn't clean these up. This could leak state to subsequent tests.

     protected function tearDown(): void
     {
         \putenv('TEST_KEY');
-        unset($_SERVER['TEST_KEY'], $_ENV['TEST_ENV_KEY']);
+        \putenv('COMPOSER_HOME');
+        unset($_SERVER['TEST_KEY'], $_SERVER['COMPOSER_HOME'], $_ENV['TEST_ENV_KEY']);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
protected function tearDown(): void
{
\putenv('TEST_KEY');
unset($_SERVER['TEST_KEY'], $_ENV['TEST_ENV_KEY']);
}
protected function tearDown(): void
{
\putenv('TEST_KEY');
\putenv('COMPOSER_HOME');
unset($_SERVER['TEST_KEY'], $_SERVER['COMPOSER_HOME'], $_ENV['TEST_ENV_KEY']);
}
🤖 Prompt for AI Agents
In tests/Process/EnvironmentProviderTest.php around lines 11 to 15, tearDown()
currently cleans TEST_KEY and TEST_ENV_KEY but misses cleaning COMPOSER_HOME
which is set by testComposerHomeNotOverwrittenIfProvidedInEnv; update tearDown()
to also remove COMPOSER_HOME by calling putenv('COMPOSER_HOME') and unsetting
$_SERVER['COMPOSER_HOME'] and $_ENV['COMPOSER_HOME'] so the environment variable
does not leak into other tests.


public function testGetEnvSetsComposerDefaults(): void
{
$parameterBag = static::getContainer()->get(ParameterBagInterface::class);
$provider = new EnvironmentProvider($parameterBag->get('composer.home'));

$env = $provider->getEnv();

$this->assertSame('/app/var/composer', $env['COMPOSER_HOME']);
$this->assertSame('1', $env['COMPOSER_NO_INTERACTION']);
}

public function testGetEnvIncludesServerVariables(): void
{
$_SERVER['TEST_KEY'] = 'value';
\putenv('TEST_KEY=value');

$parameterBag = static::getContainer()->get(ParameterBagInterface::class);
$provider = new EnvironmentProvider($parameterBag->get('composer.home'));

$env = $provider->getEnv();

$this->assertArrayHasKey('TEST_KEY', $env);
$this->assertSame('value', $env['TEST_KEY']);
}

public function testGetEnvIncludesEnvVariables(): void
{
$_ENV['TEST_ENV_KEY'] = 'env_value';

$parameterBag = static::getContainer()->get(ParameterBagInterface::class);
$provider = new EnvironmentProvider($parameterBag->get('composer.home'));

$env = $provider->getEnv();

$this->assertArrayHasKey('TEST_ENV_KEY', $env);
$this->assertSame('env_value', $env['TEST_ENV_KEY']);
}

public function testComposerHomeNotOverwrittenIfProvidedInEnv(): void
{
\putenv('COMPOSER_HOME=/app/var/composer');
$_SERVER['COMPOSER_HOME'] = '/app/var/composer';

$parameterBag = static::getContainer()->get(ParameterBagInterface::class);
$provider = new EnvironmentProvider($parameterBag->get('composer.home'));

$env = $provider->getEnv();

$this->assertSame('/app/var/composer', $env['COMPOSER_HOME']);
}
Comment on lines +55 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test assertion doesn't prove the "not overwritten" behavior.

The test sets COMPOSER_HOME=/app/var/composer in the environment, which is the same value as the parameter default. To truly verify that the environment value is preserved and the default is not used, provide a different value in the environment than the constructor argument.

     public function testComposerHomeNotOverwrittenIfProvidedInEnv(): void
     {
-        \putenv('COMPOSER_HOME=/app/var/composer');
-        $_SERVER['COMPOSER_HOME'] = '/app/var/composer';
+        \putenv('COMPOSER_HOME=/custom/composer/path');
+        $_SERVER['COMPOSER_HOME'] = '/custom/composer/path';

         $parameterBag = static::getContainer()->get(ParameterBagInterface::class);
         $provider     = new EnvironmentProvider($parameterBag->get('composer.home'));

         $env = $provider->getEnv();

-        $this->assertSame('/app/var/composer', $env['COMPOSER_HOME']);
+        $this->assertSame('/custom/composer/path', $env['COMPOSER_HOME']);
     }
🤖 Prompt for AI Agents
In tests/Process/EnvironmentProviderTest.php around lines 55 to 66, the test
uses the same COMPOSER_HOME value for both the environment and the constructor
parameter so it doesn't prove "not overwritten"; change the environment value to
a different path than the constructor argument (e.g. keep the constructor
argument as '/app/var/composer' but set putenv and $_SERVER['COMPOSER_HOME'] to
a different value like '/custom/composer') and assert that getEnv() returns the
environment-provided value to verify the default wasn't used.

}
51 changes: 51 additions & 0 deletions tests/Process/ProcessFactoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace App\Tests\Process;

use App\Process\EnvironmentProvider;
use App\Process\ProcessFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Process;

class ProcessFactoryTest extends TestCase
{
public function testCreateReturnsConfiguredProcess(): void
{
$env = ['A' => 'B'];

$provider = $this->createMock(EnvironmentProvider::class);
$provider->method('getEnv')->willReturn($env);

$factory = new ProcessFactory('/root', $provider);

$process = $factory->create(['bin/tool', 'arg1', 'arg2'], 123);

$this->assertInstanceOf(Process::class, $process);
$this->assertSame('\'/root/bin/tool\' \'arg1\' \'arg2\'', $process->getCommandLine());
$this->assertSame('/root', $process->getWorkingDirectory());
$this->assertSame($env, $process->getEnv());
$this->assertSame(123.0, $process->getTimeout());
}

public function testCreateThrowsOnEmptyCommand(): void
{
$provider = $this->createMock(EnvironmentProvider::class);
$factory = new ProcessFactory('/root', $provider);

$this->expectException(\InvalidArgumentException::class);

$factory->create([]);
}

public function testCommandIsPrefixedWithRootPath(): void
{
$provider = $this->createMock(EnvironmentProvider::class);
$provider->method('getEnv')->willReturn([]);

$factory = new ProcessFactory('/abc', $provider);

$process = $factory->create(['vendor/bin/satis']);

$this->assertSame('\'/abc/vendor/bin/satis\'', $process->getCommandLine());
}
}
81 changes: 81 additions & 0 deletions tests/Service/LockProcessorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace App\Tests\Service;

use App\DTO\Repository;
use App\Service\LockProcessor;
use App\Service\RepositoryManager;
use PHPUnit\Framework\TestCase;

final class LockProcessorTest extends TestCase
{
public function testProcessFileAddsRepositories(): void
{
$json = <<<JSON
{
"packages": [
{
"name": "vendor/package1",
"source": { "url": "https://example.com/repo1.git", "type": "git" }
}
],
"packages-dev": [
{
"name": "vendor/package2",
"source": { "url": "https://example.com/repo2.git", "type": "git" }
}
]
}
JSON;

$tmpFile = \tmpfile();
\fwrite($tmpFile, $json);
$path = \stream_get_meta_data($tmpFile)['uri'];
$file = new \SplFileObject($path);

$manager = $this->createMock(RepositoryManager::class);
$manager->expects($this->once())
->method('addAll')
->with($this->callback(function ($repositories) {
return 2 === \count($repositories)
&& $repositories[0] instanceof Repository
&& 'https://example.com/repo1.git' === $repositories[0]->getUrl()
&& 'git' === $repositories[0]->getType()
&& $repositories[1] instanceof Repository
&& 'https://example.com/repo2.git' === $repositories[1]->getUrl()
&& 'git' === $repositories[1]->getType();
}));

$processor = new LockProcessor($manager);
$processor->processFile($file);

\fclose($tmpFile);
}

public function testGetRepositoriesFiltersInvalidPackages(): void
{
$manager = $this->createMock(RepositoryManager::class);
$processor = new LockProcessor($manager);

$packages = [
(object) [
'name' => 'vendor/valid',
'source' => (object) ['url' => 'https://example.com/repo.git', 'type' => 'git'],
],
(object) [
'name' => 'vendor/invalid',
'source' => (object) ['url' => '', 'type' => ''],
],
(object) [
'name' => 'vendor/nosource',
],
];

$method = new \ReflectionMethod(LockProcessor::class, 'getRepositories');
$repositories = $method->invoke($processor, $packages);

$this->assertCount(1, $repositories);
$this->assertSame('https://example.com/repo.git', $repositories[0]->getUrl());
$this->assertSame('git', $repositories[0]->getType());
}
}
Loading