From 51014bbbe0814922273d4a41233321c2da38b6a1 Mon Sep 17 00:00:00 2001 From: Nicky Gerritsen Date: Sat, 4 Jul 2020 13:34:43 +0200 Subject: [PATCH] Add support for showing all package versions. Refs #175. --- config/packages/security.yaml | 1 + phpstan.neon | 2 +- src/Controller/OrganizationController.php | 13 +++ src/Entity/Organization/Package.php | 49 +++++++- src/Entity/Organization/Package/Version.php | 107 ++++++++++++++++++ src/Migrations/Version20200706181929.php | 42 +++++++ src/Query/User/Model/Version.php | 41 +++++++ src/Query/User/PackageQuery.php | 8 ++ .../User/PackageQuery/DbalPackageQuery.php | 42 +++++++ src/Service/Dist/Storage.php | 2 + src/Service/Dist/Storage/FileStorage.php | 5 + src/Service/Dist/Storage/InMemoryStorage.php | 5 + .../ComposerPackageSynchronizer.php | 19 +++- src/Service/Twig/BytesExtension.php | 29 +++++ .../organization/package/details.html.twig | 85 ++++++++++++++ templates/organization/packages.html.twig | 11 +- tests/Doubles/FakePackageSynchronizer.php | 21 +++- .../Controller/OrganizationControllerTest.php | 27 +++++ tests/Integration/FixturesManager.php | 8 +- tests/MotherObject/PackageMother.php | 6 +- tests/Unit/Entity/PackageTest.php | 62 +++++++++- .../ComposerPackageSynchronizerTest.php | 16 ++- .../Unit/Service/Twig/BytesExtensionTest.php | 46 ++++++++ 23 files changed, 635 insertions(+), 12 deletions(-) create mode 100644 src/Entity/Organization/Package/Version.php create mode 100644 src/Migrations/Version20200706181929.php create mode 100644 src/Query/User/Model/Version.php create mode 100644 src/Service/Twig/BytesExtension.php create mode 100644 templates/organization/package/details.html.twig create mode 100644 tests/Unit/Service/Twig/BytesExtensionTest.php diff --git a/config/packages/security.yaml b/config/packages/security.yaml index d6a3179d..5946b7ee 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -49,6 +49,7 @@ security: - { path: ^/$, roles: ROLE_USER } - { path: ^/organization/.+/overview$, roles: ROLE_ORGANIZATION_ANONYMOUS_USER } - { path: ^/organization/.+/package$, roles: ROLE_ORGANIZATION_ANONYMOUS_USER } + - { path: ^/organization/.+/package/.+/details$, roles: ROLE_ORGANIZATION_ANONYMOUS_USER } - { path: ^/organization/.+(/.+)*, roles: ROLE_ORGANIZATION_MEMBER } - { path: ^/downloads, host: '([a-z0-9_-]+)\.repo\.(.+)', roles: IS_AUTHENTICATED_ANONYMOUSLY} - { path: ^/, host: '([a-z0-9_-]+)\.repo\.(.+)', roles: ROLE_ORGANIZATION } diff --git a/phpstan.neon b/phpstan.neon index 3148ecad..24d986dc 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -20,7 +20,7 @@ parameters: path: src/Service/Twig/DateExtension.php - message: "#^Expression on left side of \\?\\? is not nullable\\.$#" - count: 2 + count: 4 path: src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php - message: "#^Variable \\$http_response_header in isset\\(\\) always exists and is not nullable\\.$#" diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index ad2b5e4f..39c4285c 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -141,6 +141,19 @@ public function removePackage(Organization $organization, Package $package): Res return $this->redirectToRoute('organization_packages', ['organization' => $organization->alias()]); } + /** + * @Route("/organization/{organization}/package/{package}/details", name="organization_package_details", methods={"GET"}, requirements={"organization"="%organization_pattern%","package"="%uuid_pattern%"}) + */ + public function packageDetails(Organization $organization, Package $package, Request $request): Response + { + return $this->render('organization/package/details.html.twig', [ + 'organization' => $organization, + 'package' => $package, + 'count' => $this->packageQuery->versionCount($package->id()), + 'versions' => $this->packageQuery->getVersions($package->id(), 20, (int) $request->get('offset', 0)), + ]); + } + /** * @Route("/organization/{organization}/package/{package}/stats", name="organization_package_stats", methods={"GET"}, requirements={"organization"="%organization_pattern%","package"="%uuid_pattern%"}) */ diff --git a/src/Entity/Organization/Package.php b/src/Entity/Organization/Package.php index f4bc8bde..d1b38d25 100644 --- a/src/Entity/Organization/Package.php +++ b/src/Entity/Organization/Package.php @@ -5,6 +5,9 @@ namespace Buddy\Repman\Entity\Organization; use Buddy\Repman\Entity\Organization; +use Buddy\Repman\Entity\Organization\Package\Version; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Ramsey\Uuid\UuidInterface; @@ -100,6 +103,12 @@ class Package */ private ?\DateTimeImmutable $lastScanDate = null; + /** + * @var Collection|Version[] + * @ORM\OneToMany(targetEntity="Buddy\Repman\Entity\Organization\Package\Version", mappedBy="package", cascade={"persist"}, orphanRemoval=true) + */ + private Collection $versions; + /** * @param mixed[] $metadata */ @@ -109,6 +118,7 @@ public function __construct(UuidInterface $id, string $type, string $url, array $this->type = $type; $this->repositoryUrl = $url; $this->metadata = $metadata; + $this->versions = new ArrayCollection(); } public function id(): UuidInterface @@ -134,12 +144,20 @@ public function repositoryUrl(): string return $this->repositoryUrl; } - public function syncSuccess(string $name, string $description, string $latestReleasedVersion, \DateTimeImmutable $latestReleaseDate): void + /** + * @param string[] $encounteredVersions + */ + public function syncSuccess(string $name, string $description, string $latestReleasedVersion, array $encounteredVersions, \DateTimeImmutable $latestReleaseDate): void { $this->setName($name); $this->description = $description; $this->latestReleasedVersion = $latestReleasedVersion; $this->latestReleaseDate = $latestReleaseDate; + foreach ($this->versions as $version) { + if (!in_array($version->version(), $encounteredVersions, true)) { + $this->versions->removeElement($version); + } + } $this->lastSyncAt = new \DateTimeImmutable(); $this->lastSyncError = null; } @@ -246,4 +264,33 @@ private function setName(string $name): void $this->name = $name; } + + /** + * @return Collection|Version[] + */ + public function versions(): Collection + { + return $this->versions; + } + + public function addOrUpdateVersion(Version $version): void + { + if ($this->getVersion($version->version()) !== false) { + $this->getVersion($version->version())->setReference($version->reference()); + $this->getVersion($version->version())->setSize($version->size()); + + return; + } + + $version->setPackage($this); + $this->versions->add($version); + } + + /** + * @return Version|false + */ + public function getVersion(string $versionString) + { + return $this->versions->filter(fn (Version $version) => $version->version() === $versionString)->first(); + } } diff --git a/src/Entity/Organization/Package/Version.php b/src/Entity/Organization/Package/Version.php new file mode 100644 index 00000000..66f997e0 --- /dev/null +++ b/src/Entity/Organization/Package/Version.php @@ -0,0 +1,107 @@ +id = $id; + $this->version = $version; + $this->reference = $reference; + $this->size = $size; + $this->date = $date; + } + + public function id(): UuidInterface + { + return $this->id; + } + + public function version(): string + { + return $this->version; + } + + public function reference(): string + { + return $this->reference; + } + + public function size(): int + { + return $this->size; + } + + public function setReference(string $reference): void + { + $this->reference = $reference; + } + + public function setSize(int $size): void + { + $this->size = $size; + } + + public function setPackage(Package $package): void + { + if (isset($this->package)) { + throw new \RuntimeException('You can not change version package'); + } + $this->package = $package; + } +} diff --git a/src/Migrations/Version20200706181929.php b/src/Migrations/Version20200706181929.php new file mode 100644 index 00000000..a91bc0e9 --- /dev/null +++ b/src/Migrations/Version20200706181929.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('CREATE TABLE organization_package_version (id UUID NOT NULL, package_id UUID NOT NULL, version VARCHAR(255) NOT NULL, reference VARCHAR(255) NOT NULL, size INT NOT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX version_package_id_idx ON organization_package_version (package_id)'); + $this->addSql('CREATE INDEX version_date_idx ON organization_package_version (date)'); + $this->addSql('CREATE UNIQUE INDEX package_version ON organization_package_version (package_id, version)'); + $this->addSql('COMMENT ON COLUMN organization_package_version.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN organization_package_version.package_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN organization_package_version.date IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE organization_package_version ADD CONSTRAINT FK_62DF469AF44CABFF FOREIGN KEY (package_id) REFERENCES organization_package (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('DROP TABLE organization_package_version'); + } +} diff --git a/src/Query/User/Model/Version.php b/src/Query/User/Model/Version.php new file mode 100644 index 00000000..40e9da58 --- /dev/null +++ b/src/Query/User/Model/Version.php @@ -0,0 +1,41 @@ +version = $version; + $this->reference = $reference; + $this->size = $size; + $this->date = $date; + } + + public function version(): string + { + return $this->version; + } + + public function reference(): string + { + return $this->reference; + } + + public function size(): int + { + return $this->size; + } + + public function date(): \DateTimeImmutable + { + return $this->date; + } +} diff --git a/src/Query/User/PackageQuery.php b/src/Query/User/PackageQuery.php index 3c009e48..929c9ae3 100644 --- a/src/Query/User/PackageQuery.php +++ b/src/Query/User/PackageQuery.php @@ -8,6 +8,7 @@ use Buddy\Repman\Query\User\Model\Package; use Buddy\Repman\Query\User\Model\PackageName; use Buddy\Repman\Query\User\Model\ScanResult; +use Buddy\Repman\Query\User\Model\Version; use Buddy\Repman\Query\User\Model\WebhookRequest; use Munus\Control\Option; @@ -30,6 +31,13 @@ public function count(string $organizationId): int; */ public function getById(string $id): Option; + public function versionCount(string $packageId): int; + + /** + * @return Version[] + */ + public function getVersions(string $packageId, int $limit = 20, int $offset = 0): array; + public function getInstalls(string $packageId, int $lastDays = 30, ?string $version = null): Installs; /** diff --git a/src/Query/User/PackageQuery/DbalPackageQuery.php b/src/Query/User/PackageQuery/DbalPackageQuery.php index 71c6ad5b..c66aeca8 100644 --- a/src/Query/User/PackageQuery/DbalPackageQuery.php +++ b/src/Query/User/PackageQuery/DbalPackageQuery.php @@ -8,6 +8,7 @@ use Buddy\Repman\Query\User\Model\Package; use Buddy\Repman\Query\User\Model\PackageName; use Buddy\Repman\Query\User\Model\ScanResult; +use Buddy\Repman\Query\User\Model\Version; use Buddy\Repman\Query\User\Model\WebhookRequest; use Buddy\Repman\Query\User\PackageQuery; use Doctrine\DBAL\Connection; @@ -103,6 +104,47 @@ public function getById(string $id): Option return Option::some($this->hydratePackage($data)); } + public function versionCount(string $packageId): int + { + return (int) $this + ->connection + ->fetchColumn( + 'SELECT COUNT(id) FROM "organization_package_version" + WHERE package_id = :package_id', + [ + ':package_id' => $packageId, + ] + ); + } + + /** + * @return Version[] + */ + public function getVersions(string $packageId, int $limit = 20, int $offset = 0): array + { + return array_map(function (array $data): Version { + return new Version( + $data['version'], + $data['reference'], + $data['size'], + new \DateTimeImmutable($data['date']) + ); + }, $this->connection->fetchAll( + 'SELECT + version, + reference, + size, + date + FROM organization_package_version + WHERE package_id = :package_id + ORDER BY date DESC + LIMIT :limit OFFSET :offset', [ + ':package_id' => $packageId, + ':limit' => $limit, + ':offset' => $offset, + ])); + } + public function getInstalls(string $packageId, int $lastDays = 30, ?string $version = null): Installs { $params = [ diff --git a/src/Service/Dist/Storage.php b/src/Service/Dist/Storage.php index b0df192f..9f262d29 100644 --- a/src/Service/Dist/Storage.php +++ b/src/Service/Dist/Storage.php @@ -18,6 +18,8 @@ public function download(string $url, Dist $dist, array $headers = []): void; public function filename(Dist $dist): string; + public function size(Dist $dist): int; + /** * @return GenericList */ diff --git a/src/Service/Dist/Storage/FileStorage.php b/src/Service/Dist/Storage/FileStorage.php index 812fd5cf..ee365b2a 100644 --- a/src/Service/Dist/Storage/FileStorage.php +++ b/src/Service/Dist/Storage/FileStorage.php @@ -64,6 +64,11 @@ public function filename(Dist $dist): string ); } + public function size(Dist $dist): int + { + return filesize($this->filename($dist)) ?: 0; + } + /** * @return GenericList */ diff --git a/src/Service/Dist/Storage/InMemoryStorage.php b/src/Service/Dist/Storage/InMemoryStorage.php index 1ba0d01c..8a088863 100644 --- a/src/Service/Dist/Storage/InMemoryStorage.php +++ b/src/Service/Dist/Storage/InMemoryStorage.php @@ -43,6 +43,11 @@ public function filename(Dist $dist): string ); } + public function size(Dist $dist): int + { + return 0; + } + public function packages(string $repo): GenericList { return GenericList::ofAll($this->dists); diff --git a/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php b/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php index 064fe6dd..b6c40b4e 100644 --- a/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php +++ b/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php @@ -5,6 +5,7 @@ namespace Buddy\Repman\Service\PackageSynchronizer; use Buddy\Repman\Entity\Organization\Package; +use Buddy\Repman\Entity\Organization\Package\Version; use Buddy\Repman\Repository\PackageRepository; use Buddy\Repman\Service\Dist; use Buddy\Repman\Service\Dist\Storage; @@ -19,6 +20,7 @@ use Composer\Repository\RepositoryFactory; use Composer\Repository\RepositoryInterface; use Composer\Semver\Comparator; +use Ramsey\Uuid\Uuid; use Symfony\Component\Console\Output\OutputInterface; final class ComposerPackageSynchronizer implements PackageSynchronizer @@ -71,13 +73,27 @@ public function synchronize(Package $package): void throw new \RuntimeException("Package {$name} already exists. Package name must be unique within organization."); } + $encounteredVersions = []; foreach ($packages as $p) { if ($p->getDistUrl() !== null) { + $dist = new Dist($package->organizationAlias(), $p->getPrettyName(), $p->getVersion(), $p->getDistReference() ?? $p->getDistSha1Checksum(), $p->getDistType()); + $this->distStorage->download( $p->getDistUrl(), - new Dist($package->organizationAlias(), $p->getPrettyName(), $p->getVersion(), $p->getDistReference() ?? $p->getDistSha1Checksum(), $p->getDistType()), + $dist, $this->getAuthHeaders($package) ); + + $package->addOrUpdateVersion( + new Version( + Uuid::uuid4(), + $p->getPrettyVersion(), + $p->getDistReference() ?? $p->getDistSha1Checksum(), + $this->distStorage->size($dist), + \DateTimeImmutable::createFromMutable($p->getReleaseDate() ?? new \DateTime()) + ) + ); + $encounteredVersions[] = $p->getPrettyVersion(); } } @@ -85,6 +101,7 @@ public function synchronize(Package $package): void $name, $latest instanceof CompletePackage ? ($latest->getDescription() ?? 'n/a') : 'n/a', $latest->getStability() === 'stable' ? $latest->getPrettyVersion() : 'no stable release', + $encounteredVersions, \DateTimeImmutable::createFromMutable($latest->getReleaseDate() ?? new \DateTime()), ); diff --git a/src/Service/Twig/BytesExtension.php b/src/Service/Twig/BytesExtension.php new file mode 100644 index 00000000..fbdb93a5 --- /dev/null +++ b/src/Service/Twig/BytesExtension.php @@ -0,0 +1,29 @@ + + {% include 'svg/package.svg' %} Package list + +{% endblock %} + +{% block content %} + + {% if package.name %} +

Package name

+

{{ package.name }}

+ +

Description

+

{{ package.description }}

+ + {% if package.latestReleaseDate %} +

Latest version

+

{{ package.latestReleasedVersion }}

+ +

Released

+

+ {{ package.latestReleaseDate | time_diff }} +

+ +

Composer require command

+
+
+ + + + +
+
+ + {% endif %} + +

Available versions

+ + + + + + + + + + + + + {% for version in versions %} + + + + + + + + {% endfor %} + +
VersionReleasedReferenceSize
{{ version.version }} + {{ version.date | time_diff }} + {{ version.reference }}{{ version.size | format_bytes }} +
+ + + + +
+
+ {% include 'component/pagination.html.twig' with {'path_name': 'organization_package_details', 'path_params': {'organization': organization.alias, 'package': package.id}} %} + {% else %} +
+ This package is not synced yet! +
+ {% endif %} + +{% endblock %} diff --git a/templates/organization/packages.html.twig b/templates/organization/packages.html.twig index 8e2b2767..3f7d4dfd 100644 --- a/templates/organization/packages.html.twig +++ b/templates/organization/packages.html.twig @@ -16,7 +16,7 @@ Name - Version + Latest version Released Description {% if is_granted('ROLE_ORGANIZATION_MEMBER', organization) %} @@ -31,7 +31,11 @@ {% if package.name %} - {{ package.name }}
+ + + {{ package.name }} + +
{{ package.url }}
@@ -93,6 +97,9 @@