Skip to content

Commit 0555e7a

Browse files
authored
Merge branch 'main' into maintainer-events
2 parents a93c9d4 + decd0cf commit 0555e7a

File tree

6 files changed

+443
-1
lines changed

6 files changed

+443
-1
lines changed

src/Audit/AuditRecordType.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ enum AuditRecordType: string
1717
// package ownership
1818
case MaintainerAdded = 'maintainer_added';
1919
case MaintainerRemoved = 'maintainer_removed';
20-
case PackageTransferred = 'package_transferred'; // TODO
20+
case PackageTransferred = 'package_transferred';
2121

2222
// package management
2323
case PackageCreated = 'package_created';
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Command;
14+
15+
use App\Entity\AuditRecord;
16+
use App\Entity\Package;
17+
use App\Entity\User;
18+
use App\Util\DoctrineTrait;
19+
use Composer\Console\Input\InputOption;
20+
use Doctrine\Persistence\ManagerRegistry;
21+
use Symfony\Component\Console\Command\Command;
22+
use Symfony\Component\Console\Helper\Table;
23+
use Symfony\Component\Console\Input\InputArgument;
24+
use Symfony\Component\Console\Input\InputInterface;
25+
use Symfony\Component\Console\Output\OutputInterface;
26+
27+
class TransferOwnershipCommand extends Command
28+
{
29+
use DoctrineTrait;
30+
31+
public function __construct(
32+
private readonly ManagerRegistry $doctrine,
33+
)
34+
{
35+
parent::__construct();
36+
}
37+
38+
protected function configure(): void
39+
{
40+
$this
41+
->setName('packagist:transfer-ownership')
42+
->setDescription('Transfer all packages of a vendor')
43+
->setDefinition([
44+
new InputArgument('vendorOrPackage', InputArgument::REQUIRED,'Vendor or package name'),
45+
new InputArgument('maintainers', InputArgument::IS_ARRAY|InputArgument::REQUIRED, 'The usernames of the new maintainers'),
46+
new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run'),
47+
])
48+
;
49+
}
50+
51+
protected function execute(InputInterface $input, OutputInterface $output): int
52+
{
53+
$dryRun = $input->getOption('dry-run');
54+
55+
if ($dryRun) {
56+
$output->writeln('ℹ️ DRY RUN');
57+
}
58+
59+
$vendorOrPackage = $input->getArgument('vendorOrPackage');
60+
$maintainers = $this->queryAndValidateMaintainers($input, $output);
61+
62+
if (!count($maintainers)) {
63+
return Command::FAILURE;
64+
}
65+
66+
$packages = $this->queryPackages($vendorOrPackage);
67+
68+
if (!count($packages)) {
69+
$output->writeln(sprintf('<error>No packages found for %s</error>', $vendorOrPackage));
70+
return Command::FAILURE;
71+
}
72+
73+
$this->outputPackageTable($output, $packages, $maintainers);
74+
75+
if (!$dryRun) {
76+
$this->transferOwnership($packages, $maintainers);
77+
}
78+
79+
return Command::SUCCESS;
80+
}
81+
82+
/**
83+
* @return User[]
84+
*/
85+
private function queryAndValidateMaintainers(InputInterface $input, OutputInterface $output): array
86+
{
87+
$usernames = array_map('mb_strtolower', $input->getArgument('maintainers'));
88+
sort($usernames);
89+
90+
$maintainers = $this->getEM()->getRepository(User::class)->findUsersByUsername($usernames, ['usernameCanonical' => 'ASC']);
91+
92+
if (array_keys($maintainers) === $usernames) {
93+
return $maintainers;
94+
}
95+
96+
$notFound = [];
97+
98+
foreach ($usernames as $username) {
99+
if (!array_key_exists($username, $maintainers)) {
100+
$notFound[] = $username;
101+
}
102+
}
103+
104+
sort($notFound);
105+
106+
$output->writeln(sprintf('<error>%d maintainers could not be found: %s</error>', count($notFound), implode(', ', $notFound)));
107+
108+
return [];
109+
}
110+
111+
/**
112+
* @return Package[]
113+
*/
114+
private function queryPackages(string $vendorOrPackage): array
115+
{
116+
$repository = $this->getEM()->getRepository(Package::class);
117+
$isPackageName = str_contains($vendorOrPackage, '/');
118+
119+
if ($isPackageName) {
120+
$package = $repository->findOneBy(['name' => $vendorOrPackage]);
121+
122+
return $package ? [$package] : [];
123+
}
124+
125+
return $repository->findBy([
126+
'vendor' => $vendorOrPackage
127+
]);
128+
}
129+
130+
/**
131+
* @param Package[] $packages
132+
* @param User[] $maintainers
133+
*/
134+
private function outputPackageTable(OutputInterface $output, array $packages, array $maintainers): void
135+
{
136+
$rows = [];
137+
138+
usort($packages, fn (Package $a, Package $b) => strcasecmp($a->getName(), $b->getName()));
139+
140+
$newMaintainers = array_map(fn (User $user) => $user->getUsername(), $maintainers);
141+
142+
foreach ($packages as $package) {
143+
$currentMaintainers = $package->getMaintainers()->map(fn (User $user) => $user->getUsername())->toArray();
144+
sort($currentMaintainers);
145+
146+
$rows[] = [
147+
$package->getVendor(),
148+
$package->getPackageName(),
149+
implode(', ', $currentMaintainers),
150+
implode(', ', $newMaintainers),
151+
];
152+
}
153+
154+
$table = new Table($output);
155+
$table
156+
->setHeaders(['Vendor', 'Package', 'Current Maintainers', 'New Maintainers'])
157+
->setRows($rows)
158+
;
159+
$table->render();
160+
}
161+
162+
/**
163+
* @param Package[] $packages
164+
* @param User[] $maintainers
165+
*/
166+
private function transferOwnership(array $packages, array $maintainers): void
167+
{
168+
$normalizedMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $maintainers));
169+
sort($normalizedMaintainers, SORT_NUMERIC);
170+
171+
foreach ($packages as $package) {
172+
$oldMaintainers = $package->getMaintainers()->toArray();
173+
174+
$normalizedOldMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $oldMaintainers));
175+
sort($normalizedOldMaintainers, SORT_NUMERIC);
176+
if ($normalizedMaintainers === $normalizedOldMaintainers) {
177+
continue;
178+
}
179+
180+
$package->getMaintainers()->clear();
181+
foreach ($maintainers as $maintainer) {
182+
$package->addMaintainer($maintainer);
183+
}
184+
185+
$this->doctrine->getManager()->persist(AuditRecord::packageTransferred($package, null, $oldMaintainers, array_values($maintainers)));
186+
}
187+
188+
$this->doctrine->getManager()->flush();
189+
}
190+
}

src/Entity/AuditRecord.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ public static function canonicalUrlChange(Package $package, ?User $actor, string
6767
return new self(AuditRecordType::CanonicalUrlChanged, ['name' => $package->getName(), 'repository_from' => $oldRepository, 'repository_to' => $package->getRepository(), 'actor' => self::getUserData($actor)], $actor?->getId(), $package->getVendor(), $package->getId());
6868
}
6969

70+
/**
71+
* @param User[] $previousMaintainers
72+
* @param User[] $currentMaintainers
73+
*/
74+
public static function packageTransferred(Package $package, ?User $actor, array $previousMaintainers, array $currentMaintainers): self
75+
{
76+
$callback = fn (User $user) => self::getUserData($user);
77+
$previous = array_map($callback, $previousMaintainers);
78+
$current = array_map($callback, $currentMaintainers);
79+
80+
return new self(AuditRecordType::PackageTransferred, ['name' => $package->getName(), 'actor' => self::getUserData($actor, 'admin'), 'previous_maintainers' => $previous, 'current_maintainers' => $current], $actor?->getId(), $package->getVendor(), $package->getId());
81+
}
82+
7083
public static function versionDeleted(Version $version, ?User $actor): self
7184
{
7285
$package = $version->getPackage();

src/Entity/UserRepository.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ public function findOneByUsernameOrEmail(string $usernameOrEmail): ?User
4141
return $this->findOneBy(['usernameCanonical' => $usernameOrEmail]);
4242
}
4343

44+
/**
45+
* @param string[] $usernames
46+
* @param ?array<string, string> $orderBy
47+
* @return array<string, User>
48+
*/
49+
public function findUsersByUsername(array $usernames, ?array $orderBy = null): array
50+
{
51+
$matches = $this->findBy(['usernameCanonical' => $usernames], $orderBy);
52+
53+
$users = [];
54+
foreach ($matches as $match) {
55+
$users[$match->getUsernameCanonical()] = $match;
56+
}
57+
58+
return $users;
59+
}
60+
4461
/**
4562
* @return list<User>
4663
*/

0 commit comments

Comments
 (0)