+ {% if is_granted('add_maintainer', package) %}{% endif %}
+ {% if is_granted('remove_maintainer', package) %}{% endif %}
+ {% if is_granted('transfer_package', package) %}{% endif %}
+
{% endif %}
{{ form_start(transferPackageForm, {
- attr: { id: 'transfer-package-form', class: 'col-sm-6 col-md-3 col-md-offset-9 ' ~ (show_transfer_package_form|default(false) ? '': 'hidden') },
+ attr: {
+ id: 'transfer-package-form',
+ class: 'col-sm-6 col-md-3 col-md-offset-9 ' ~ (show_transfer_package_form|default(false) ? '': 'hidden'),
+ },
action: path('transfer_package', { 'name': package.name })
}) }}
From 628c878483a85cc0069b8dc17c65c8a7b3ab0e56 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Tue, 9 Dec 2025 14:17:44 +0100
Subject: [PATCH 12/17] Exclude users that are disabled
---
src/Command/TransferOwnershipCommand.php | 2 +-
src/Entity/UserRepository.php | 7 +++++--
src/Form/Type/MaintainerType.php | 9 +++++++++
tests/Command/TransferOwnershipCommandTest.php | 16 ++++++++++++++++
tests/Controller/PackageControllerTest.php | 4 +++-
tests/Entity/UserRepositoryTest.php | 5 +++--
tests/IntegrationTestCase.php | 2 +-
7 files changed, 38 insertions(+), 7 deletions(-)
diff --git a/src/Command/TransferOwnershipCommand.php b/src/Command/TransferOwnershipCommand.php
index b31125ce0..b2632097e 100644
--- a/src/Command/TransferOwnershipCommand.php
+++ b/src/Command/TransferOwnershipCommand.php
@@ -89,7 +89,7 @@ private function queryAndValidateMaintainers(InputInterface $input, OutputInterf
$usernames = array_map('mb_strtolower', $input->getArgument('maintainers'));
sort($usernames);
- $maintainers = $this->getEM()->getRepository(User::class)->findUsersByUsername($usernames, ['usernameCanonical' => 'ASC']);
+ $maintainers = $this->getEM()->getRepository(User::class)->findEnabledUsersByUsername($usernames, ['usernameCanonical' => 'ASC']);
if (array_keys($maintainers) === $usernames) {
return $maintainers;
diff --git a/src/Entity/UserRepository.php b/src/Entity/UserRepository.php
index 028d8a525..352d851c9 100644
--- a/src/Entity/UserRepository.php
+++ b/src/Entity/UserRepository.php
@@ -46,9 +46,12 @@ public function findOneByUsernameOrEmail(string $usernameOrEmail): ?User
* @param ?array $orderBy
* @return array
*/
- public function findUsersByUsername(array $usernames, ?array $orderBy = null): array
+ public function findEnabledUsersByUsername(array $usernames, ?array $orderBy = null): array
{
- $matches = $this->findBy(['usernameCanonical' => $usernames], $orderBy);
+ $matches = $this->findBy([
+ 'usernameCanonical' => $usernames,
+ 'enabled' => true,
+ ], $orderBy);
$users = [];
foreach ($matches as $match) {
diff --git a/src/Form/Type/MaintainerType.php b/src/Form/Type/MaintainerType.php
index 930c9457f..db1bd8dda 100644
--- a/src/Form/Type/MaintainerType.php
+++ b/src/Form/Type/MaintainerType.php
@@ -53,6 +53,15 @@ function (?string $username): ?User {
throw $failure;
}
+ if (!$user->isEnabled()) {
+ $failure = new TransformationFailedException(sprintf('User "%s" is disabled.', $username));
+ $failure->setInvalidMessage('The user "{{ value }}" is disabled.', [
+ '{{ value }}' => $username,
+ ]);
+
+ throw $failure;
+ }
+
return $user;
}
));
diff --git a/tests/Command/TransferOwnershipCommandTest.php b/tests/Command/TransferOwnershipCommandTest.php
index d21946a80..deaefa774 100644
--- a/tests/Command/TransferOwnershipCommandTest.php
+++ b/tests/Command/TransferOwnershipCommandTest.php
@@ -132,6 +132,22 @@ public function testExecuteFailsWithUnknownMaintainers(): void
$this->assertStringContainsString('2 maintainers could not be found', $output);
}
+ public function testExecuteFailsWithDisabledMaintainers(): void
+ {
+ $tom = self::createUser('tom', 'tom@example.org', enabled: false);
+ $this->store($tom);
+
+ $this->commandTester->execute([
+ 'vendorOrPackage' => 'vendor1',
+ 'maintainers' => ['alice', 'tom'],
+ ]);
+
+ $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('1 maintainers could not be found', $output);
+ }
+
public function testExecuteFailsIfNoVendorPackagesFound(): void
{
$this->commandTester->execute([
diff --git a/tests/Controller/PackageControllerTest.php b/tests/Controller/PackageControllerTest.php
index b44d064d2..7e78c1c40 100644
--- a/tests/Controller/PackageControllerTest.php
+++ b/tests/Controller/PackageControllerTest.php
@@ -196,12 +196,14 @@ public function testTransferPackage(): void
#[TestWith(['does_not_exist', 'value is not a valid username or email'])]
#[TestWith([null, 'at least one maintainer must be specified'])]
+ #[TestWith(['bob', 'The user "bob" is disabled'])]
public function testTransferPackageReturnsValidationError(?string $value, string $message): void
{
$alice = self::createUser('alice', 'alice@example.org');
+ $bob = self::createUser('bob', 'bob@example.org', enabled: false);
$package = self::createPackage('test/pkg', 'https://example.com/test/pkg', maintainers: [$alice]);
- $this->store($alice, $package);
+ $this->store($alice, $bob, $package);
$this->client->loginUser($alice);
diff --git a/tests/Entity/UserRepositoryTest.php b/tests/Entity/UserRepositoryTest.php
index 2e32eee7d..32119d6b2 100644
--- a/tests/Entity/UserRepositoryTest.php
+++ b/tests/Entity/UserRepositoryTest.php
@@ -31,9 +31,10 @@ public function testFindUsersByUsernameWithMultipleValidUsernames(): void
$alice = self::createUser('Alice', 'alice@example.org');
$bob = self::createUser('Bob', 'bob@example.org');
$charlie = self::createUser('Charlie', 'charlie@example.org');
- $this->store($alice, $bob, $charlie);
+ $john = self::createUser('John', 'john@example.org', enabled: false);
+ $this->store($alice, $bob, $charlie, $john);
- $result = $this->userRepository->findUsersByUsername(['alice', 'bob']);
+ $result = $this->userRepository->findEnabledUsersByUsername(['alice', 'bob', 'john']);
$this->assertCount(2, $result);
diff --git a/tests/IntegrationTestCase.php b/tests/IntegrationTestCase.php
index 05fd5eb1d..e80bfe9eb 100644
--- a/tests/IntegrationTestCase.php
+++ b/tests/IntegrationTestCase.php
@@ -104,7 +104,7 @@ protected static function createPackage(string $name, string $repository, ?strin
protected static function createUser(string $username = 'test', string $email = 'test@example.org', string $password = 'testtest', string $apiToken = 'api-token', string $safeApiToken = 'safe-api-token', string $githubId = '12345', bool $enabled = true, array $roles = []): User
{
$user = new User();
- $user->setEnabled(true);
+ $user->setEnabled($enabled);
$user->setUsername($username);
$user->setEmail($email);
$user->setPassword($password);
From 5eda6de8378b0153013bf577ab59353a577ae8fb Mon Sep 17 00:00:00 2001
From: Jordi Boggiano
Date: Wed, 10 Dec 2025 16:40:40 +0100
Subject: [PATCH 13/17] Minor tweaks/cleanups
---
src/Controller/PackageController.php | 5 ++--
src/Form/Model/TransferPackageRequest.php | 28 ++++-------------------
2 files changed, 7 insertions(+), 26 deletions(-)
diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php
index 4ba37c232..d9601239e 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -964,7 +964,7 @@ public function removeMaintainerAction(Request $req, #[MapEntity] Package $packa
}
- #[Route(path: '/packages/{name:package}/transfer/', name: 'transfer_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX])]
+ #[Route(path: '/packages/{name:package}/transfer/', name: 'transfer_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX], methods: ['GET', 'POST'])]
public function transferPackageAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] User $user, LoggerInterface $logger): RedirectResponse
{
$this->denyAccessUnlessGranted(PackageActions::TransferPackage->value, $package);
@@ -1658,8 +1658,7 @@ private function createRemoveMaintainerForm(Package $package): FormInterface
*/
private function createTransferPackageForm(Package $package): FormInterface
{
- $transferRequest = new TransferPackageRequest();
- $transferRequest->setMaintainers(clone $package->getMaintainers());
+ $transferRequest = new TransferPackageRequest(clone $package->getMaintainers());
return $this->createForm(TransferPackageRequestType::class, $transferRequest);
}
diff --git a/src/Form/Model/TransferPackageRequest.php b/src/Form/Model/TransferPackageRequest.php
index 747debb94..71fb028ee 100644
--- a/src/Form/Model/TransferPackageRequest.php
+++ b/src/Form/Model/TransferPackageRequest.php
@@ -19,28 +19,10 @@
class TransferPackageRequest
{
- /** @var Collection */
- #[TransferPackageValidMaintainersList]
- private Collection $maintainers;
-
- public function __construct()
- {
- $this->maintainers = new ArrayCollection();
- }
-
- /**
- * @return Collection
- */
- public function getMaintainers(): Collection
- {
- return $this->maintainers;
- }
-
- /**
- * @param Collection $maintainers
- */
- public function setMaintainers(Collection $maintainers): void
- {
- $this->maintainers = $maintainers;
+ public function __construct(
+ /** @var Collection */
+ #[TransferPackageValidMaintainersList]
+ public Collection $maintainers = new ArrayCollection(),
+ ) {
}
}
From 56068e08bf1c192c317d04c308e0b4994e97a686 Mon Sep 17 00:00:00 2001
From: Jordi Boggiano
Date: Wed, 10 Dec 2025 17:01:25 +0100
Subject: [PATCH 14/17] Fix usage, make regex more possessive to avoid router
issues with package names containing underscores
---
src/Controller/PackageController.php | 2 +-
src/Entity/Package.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php
index d9601239e..48e7a3fa2 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -985,7 +985,7 @@ public function transferPackageAction(Request $req, #[MapEntity] Package $packag
}
try {
- $newMaintainers = $form->getData()->getMaintainers()->toArray();
+ $newMaintainers = $form->getData()->maintainers->toArray();
$result = $this->packageManager->transferPackage($package, $newMaintainers);
$this->getEM()->flush();
diff --git a/src/Entity/Package.php b/src/Entity/Package.php
index d4a5c78b4..c96c27c0e 100644
--- a/src/Entity/Package.php
+++ b/src/Entity/Package.php
@@ -90,7 +90,7 @@ class Package
public const AUTO_MANUAL_HOOK = 1;
public const AUTO_GITHUB_HOOK = 2;
- public const string PACKAGE_NAME_REGEX = '[a-zA-Z0-9](?:[_.-]?[a-zA-Z0-9]+)*/[a-zA-Z0-9](?:[_.-]?[a-zA-Z0-9]+)*';
+ public const string PACKAGE_NAME_REGEX = '[a-zA-Z0-9]++(?:[_.-]?[a-zA-Z0-9]++)*+/[a-zA-Z0-9]++(?:[_.-]?[a-zA-Z0-9]++)*+';
#[ORM\Id]
#[ORM\Column(type: 'integer')]
From 6ce86ba5aa11478a552cd449acfc187932433562 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Wed, 10 Dec 2025 17:41:23 +0100
Subject: [PATCH 15/17] Reuse `findEnabledUsersByUsername()` in
`MaintainerType` too
---
src/Form/Type/MaintainerType.php | 18 +++++-------------
tests/Controller/PackageControllerTest.php | 5 ++---
2 files changed, 7 insertions(+), 16 deletions(-)
diff --git a/src/Form/Type/MaintainerType.php b/src/Form/Type/MaintainerType.php
index db1bd8dda..9a7cbe279 100644
--- a/src/Form/Type/MaintainerType.php
+++ b/src/Form/Type/MaintainerType.php
@@ -42,27 +42,19 @@ function (?string $username): ?User {
return null;
}
- $user = $this->em->getRepository(User::class)->findOneByUsernameOrEmail($username);
+ $username = mb_strtolower($username);
+ $users = $this->em->getRepository(User::class)->findEnabledUsersByUsername([$username]);
- if ($user === null) {
+ if (!count($users) || !array_key_exists($username, $users)) {
$failure = new TransformationFailedException(sprintf('User "%s" does not exist.', $username));
- $failure->setInvalidMessage('The given "{{ value }}" value is not a valid username or email.', [
+ $failure->setInvalidMessage('The given "{{ value }}" value is not a valid username.', [
'{{ value }}' => $username,
]);
throw $failure;
}
- if (!$user->isEnabled()) {
- $failure = new TransformationFailedException(sprintf('User "%s" is disabled.', $username));
- $failure->setInvalidMessage('The user "{{ value }}" is disabled.', [
- '{{ value }}' => $username,
- ]);
-
- throw $failure;
- }
-
- return $user;
+ return $users[$username];
}
));
}
diff --git a/tests/Controller/PackageControllerTest.php b/tests/Controller/PackageControllerTest.php
index 7e78c1c40..ae44aa960 100644
--- a/tests/Controller/PackageControllerTest.php
+++ b/tests/Controller/PackageControllerTest.php
@@ -166,7 +166,7 @@ public function testTransferPackage(): void
$form = $crawler->filter('[name="transfer_package_form"]')->form();
$form->setValues([
'transfer_package_form[maintainers][0]' => 'alice',
- 'transfer_package_form[maintainers][1]' => 'bob@example.org',
+ 'transfer_package_form[maintainers][1]' => 'bob',
]);
$this->client->submit($form);
@@ -194,9 +194,8 @@ public function testTransferPackage(): void
$this->assertNotNull($auditRecord, 'Audit record not found');
}
- #[TestWith(['does_not_exist', 'value is not a valid username or email'])]
+ #[TestWith(['does_not_exist', 'value is not a valid username'])]
#[TestWith([null, 'at least one maintainer must be specified'])]
- #[TestWith(['bob', 'The user "bob" is disabled'])]
public function testTransferPackageReturnsValidationError(?string $value, string $message): void
{
$alice = self::createUser('alice', 'alice@example.org');
From ed5e0766007d91124bece6f95e68c24641829f81 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Thu, 11 Dec 2025 12:06:49 +0100
Subject: [PATCH 16/17] Notify new maintainers when transferring ownership via
UI
---
src/Controller/PackageController.php | 2 +-
src/Model/PackageManager.php | 7 ++++++-
tests/Command/TransferOwnershipCommandTest.php | 2 ++
tests/Controller/PackageControllerTest.php | 7 +++++++
tests/Model/PackageManagerTest.php | 8 ++++++--
5 files changed, 22 insertions(+), 4 deletions(-)
diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php
index 48e7a3fa2..2f6e0ce8f 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -986,7 +986,7 @@ public function transferPackageAction(Request $req, #[MapEntity] Package $packag
try {
$newMaintainers = $form->getData()->maintainers->toArray();
- $result = $this->packageManager->transferPackage($package, $newMaintainers);
+ $result = $this->packageManager->transferPackage($package, $newMaintainers, true);
$this->getEM()->flush();
if ($result) {
diff --git a/src/Model/PackageManager.php b/src/Model/PackageManager.php
index b82be2143..d6271dcbc 100644
--- a/src/Model/PackageManager.php
+++ b/src/Model/PackageManager.php
@@ -244,7 +244,7 @@ public function notifyNewMaintainer(User $user, Package $package): bool
/**
* @param array $newMaintainers
*/
- public function transferPackage(Package $package, array $newMaintainers): bool
+ public function transferPackage(Package $package, array $newMaintainers, bool $notifyNewMaintainers = false): bool
{
$oldMaintainers = $package->getMaintainers()->toArray();
$normalizedOldMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $oldMaintainers));
@@ -259,7 +259,12 @@ public function transferPackage(Package $package, array $newMaintainers): bool
$package->getMaintainers()->clear();
foreach ($newMaintainers as $maintainer) {
+ $isNewMaintainer = !in_array($maintainer->getId(), $normalizedOldMaintainers);
$package->addMaintainer($maintainer);
+
+ if ($notifyNewMaintainers && $isNewMaintainer) {
+ $this->notifyNewMaintainer($maintainer, $package);
+ }
}
$this->doctrine->getManager()->persist(AuditRecord::packageTransferred($package, null, $oldMaintainers, $newMaintainers));
diff --git a/tests/Command/TransferOwnershipCommandTest.php b/tests/Command/TransferOwnershipCommandTest.php
index deaefa774..ea5d8db26 100644
--- a/tests/Command/TransferOwnershipCommandTest.php
+++ b/tests/Command/TransferOwnershipCommandTest.php
@@ -71,6 +71,7 @@ public function testExecuteSuccessForVendor(): void
$this->assertEqualsCanonicalizing(['alice', 'bob'], array_map($callable, $package1->getMaintainers()->toArray()));
$this->assertEqualsCanonicalizing(['alice', 'bob'], array_map($callable, $package2->getMaintainers()->toArray()));
$this->assertEqualsCanonicalizing(['john'], array_map($callable, $package3->getMaintainers()->toArray()), 'vendor2 packages should not be changed');
+ $this->assertEmailCount(0);
}
public function testExecuteSuccessForPackage(): void
@@ -94,6 +95,7 @@ public function testExecuteSuccessForPackage(): void
$callable = fn (User $user) => $user->getUsernameCanonical();
$this->assertEqualsCanonicalizing(['bob', 'john'], array_map($callable, $package2->getMaintainers()->toArray()), 'vendor1 packages should not be changed');
$this->assertEqualsCanonicalizing(['alice', 'john'], array_map($callable, $package3->getMaintainers()->toArray()));
+
}
public function testExecuteWithDryRunDoesNothing(): void
diff --git a/tests/Controller/PackageControllerTest.php b/tests/Controller/PackageControllerTest.php
index ae44aa960..17fc9c3a8 100644
--- a/tests/Controller/PackageControllerTest.php
+++ b/tests/Controller/PackageControllerTest.php
@@ -169,9 +169,16 @@ public function testTransferPackage(): void
'transfer_package_form[maintainers][1]' => 'bob',
]);
+ $this->client->enableProfiler(); // This is required in 7.3.4 to assert emails were sent, see https://github.com/symfony/symfony/issues/61873
$this->client->submit($form);
$this->assertResponseRedirects('/packages/test/pkg');
+
+ $this->assertEmailCount(1);
+ $email = $this->getMailerMessage();
+ $this->assertNotNull($email);
+ $this->assertEmailHeaderSame($email, 'To', $bob->getEmail());
+
$this->client->followRedirect();
$this->assertResponseIsSuccessful();
diff --git a/tests/Model/PackageManagerTest.php b/tests/Model/PackageManagerTest.php
index 3b00c8b35..60eaf693d 100644
--- a/tests/Model/PackageManagerTest.php
+++ b/tests/Model/PackageManagerTest.php
@@ -18,6 +18,7 @@
use App\Entity\User;
use App\Model\PackageManager;
use App\Tests\IntegrationTestCase;
+use PHPUnit\Framework\Attributes\TestWith;
class PackageManagerTest extends IntegrationTestCase
{
@@ -60,7 +61,9 @@ public function testNotifyFailure(): void
$this->assertEquals(202, $client->getResponse()->getStatusCode());
}
- public function testTransferPackageReplacesAllMaintainers(): void
+ #[TestWith([false, 0])]
+ #[TestWith([true, 1])]
+ public function testTransferPackageReplacesAllMaintainers(bool $notifyNewMaintainers, int $expectedEmailCount): void
{
$alice = self::createUser('alice', 'alice@example.org');
$bob = self::createUser('bob', 'bob@example.org');
@@ -70,7 +73,7 @@ public function testTransferPackageReplacesAllMaintainers(): void
$package = self::createPackage('vendor/package', 'https://github.com/vendor/package', maintainers: [$john, $alice]);
$this->store($package);
- $result = $this->packageManager->transferPackage($package, [$bob, $alice]);
+ $result = $this->packageManager->transferPackage($package, [$bob, $alice], $notifyNewMaintainers);
$em = self::getEM();
$em->flush();
@@ -81,6 +84,7 @@ public function testTransferPackageReplacesAllMaintainers(): void
$callable = fn (User $user) => $user->getUsernameCanonical();
$this->assertEqualsCanonicalizing(['alice', 'bob'], array_map($callable, $package->getMaintainers()->toArray()));
$this->assertAuditLogWasCreated($package, ['john', 'alice'], ['bob', 'alice']);
+ $this->assertEmailCount($expectedEmailCount);
}
public function testTransferPackageWithSameMaintainersDoesNothing(): void
From a3b623917d596c4f5e709ede0b41635d80d1e91c Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Fri, 12 Dec 2025 15:44:08 +0100
Subject: [PATCH 17/17] Remove default value for `$notifyNewMaintainers`
parameter
---
src/Command/TransferOwnershipCommand.php | 2 +-
src/Model/PackageManager.php | 2 +-
tests/Model/PackageManagerTest.php | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/Command/TransferOwnershipCommand.php b/src/Command/TransferOwnershipCommand.php
index b2632097e..feb9ac50c 100644
--- a/src/Command/TransferOwnershipCommand.php
+++ b/src/Command/TransferOwnershipCommand.php
@@ -168,7 +168,7 @@ private function outputPackageTable(OutputInterface $output, array $packages, ar
private function transferOwnership(array $packages, array $maintainers): void
{
foreach ($packages as $package) {
- $this->packageManager->transferPackage($package, $maintainers);
+ $this->packageManager->transferPackage($package, $maintainers, false);
}
$this->doctrine->getManager()->flush();
diff --git a/src/Model/PackageManager.php b/src/Model/PackageManager.php
index d6271dcbc..5ebec63bf 100644
--- a/src/Model/PackageManager.php
+++ b/src/Model/PackageManager.php
@@ -244,7 +244,7 @@ public function notifyNewMaintainer(User $user, Package $package): bool
/**
* @param array $newMaintainers
*/
- public function transferPackage(Package $package, array $newMaintainers, bool $notifyNewMaintainers = false): bool
+ public function transferPackage(Package $package, array $newMaintainers, bool $notifyNewMaintainers): bool
{
$oldMaintainers = $package->getMaintainers()->toArray();
$normalizedOldMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $oldMaintainers));
diff --git a/tests/Model/PackageManagerTest.php b/tests/Model/PackageManagerTest.php
index 60eaf693d..e4fd5cf43 100644
--- a/tests/Model/PackageManagerTest.php
+++ b/tests/Model/PackageManagerTest.php
@@ -96,7 +96,7 @@ public function testTransferPackageWithSameMaintainersDoesNothing(): void
$package = self::createPackage('vendor/package', 'https://github.com/vendor/package', maintainers: [$bob, $alice]);
$this->store($package);
- $result = $this->packageManager->transferPackage($package, [$alice, $bob]);
+ $result = $this->packageManager->transferPackage($package, [$alice, $bob], false);
$em = self::getEM();
$em->flush();