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..4be0d60d 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -141,6 +141,18 @@ 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, + 'versions' => $this->packageQuery->getVersions($package->id()), + ]); + } + /** * @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..6dd4ca41 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 @@ -246,4 +256,49 @@ 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->versions->contains($version) || $this->hasVersion($version->version())) { + $this->getVersion($version->version())->setReference($version->reference()); + + return; + } + + $version->setPackage($this); + $this->versions->add($version); + } + + public function hasVersion(string $versionString): bool + { + return $this->versions->filter(fn (Version $version) => $version->version() === $versionString)->first() !== false; + } + + /** + * @return Version|false + */ + public function getVersion(string $versionString) + { + return $this->versions->filter(fn (Version $version) => $version->version() === $versionString)->first(); + } + + /** + * @param string[] $encounteredVersions + */ + public function removeUnencounteredVersions(array $encounteredVersions): void + { + foreach ($this->versions as $version) { + if (!in_array($version->version(), $encounteredVersions, true)) { + $this->versions->removeElement($version); + } + } + } } diff --git a/src/Entity/Organization/Package/Version.php b/src/Entity/Organization/Package/Version.php new file mode 100644 index 00000000..a969ee18 --- /dev/null +++ b/src/Entity/Organization/Package/Version.php @@ -0,0 +1,90 @@ +id = $id; + $this->version = $version; + $this->reference = $reference; + $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 setReference(string $reference): void + { + $this->reference = $reference; + } + + 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/Version20200704110939.php b/src/Migrations/Version20200704110939.php new file mode 100644 index 00000000..37de7227 --- /dev/null +++ b/src/Migrations/Version20200704110939.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, 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..ffdad570 --- /dev/null +++ b/src/Query/User/Model/Version.php @@ -0,0 +1,34 @@ +version = $version; + $this->reference = $reference; + $this->date = $date; + } + + public function version(): string + { + return $this->version; + } + + public function reference(): string + { + return $this->reference; + } + + public function date(): \DateTimeImmutable + { + return $this->date; + } +} diff --git a/src/Query/User/PackageQuery.php b/src/Query/User/PackageQuery.php index 3c009e48..3e0ef171 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,11 @@ public function count(string $organizationId): int; */ public function getById(string $id): Option; + /** + * @return Version[] + */ + public function getVersions(string $packageId): 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..be49d06a 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,29 @@ public function getById(string $id): Option return Option::some($this->hydratePackage($data)); } + /** + * @return Version[] + */ + public function getVersions(string $packageId): array + { + return array_map(function (array $data): Version { + return new Version( + $data['version'], + $data['reference'], + new \DateTimeImmutable($data['date']) + ); + }, $this->connection->fetchAll( + 'SELECT + version, + reference, + date + FROM organization_package_version + WHERE package_id = :package_id + ORDER BY date DESC', [ + ':package_id' => $packageId, + ])); + } + public function getInstalls(string $packageId, int $lastDays = 30, ?string $version = null): Installs { $params = [ diff --git a/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php b/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php index 064fe6dd..21455b1a 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,6 +73,7 @@ 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) { $this->distStorage->download( @@ -78,9 +81,21 @@ public function synchronize(Package $package): void new Dist($package->organizationAlias(), $p->getPrettyName(), $p->getVersion(), $p->getDistReference() ?? $p->getDistSha1Checksum(), $p->getDistType()), $this->getAuthHeaders($package) ); + + $package->addOrUpdateVersion( + new Version( + Uuid::uuid4(), + $p->getPrettyVersion(), + $p->getDistReference() ?? $p->getDistSha1Checksum(), + \DateTimeImmutable::createFromMutable($p->getReleaseDate() ?? new \DateTime()) + ) + ); + $encounteredVersions[] = $p->getPrettyVersion(); } } + $package->removeUnencounteredVersions($encounteredVersions); + $package->syncSuccess( $name, $latest instanceof CompletePackage ? ($latest->getDescription() ?? 'n/a') : 'n/a', diff --git a/templates/organization/package/details.html.twig b/templates/organization/package/details.html.twig new file mode 100644 index 00000000..c51ce5ee --- /dev/null +++ b/templates/organization/package/details.html.twig @@ -0,0 +1,69 @@ +{% extends 'base.html.twig' %} + +{% block header %}{{ package.name }} details{% endblock %} +{% block header_btn %} + + {% 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 }} +

+ + {% endif %} + +

Available versions

+ + + + + + + + + + + + {% for version in versions %} + + + + + + + {% endfor %} + +
VersionReleasedReference
{{ version.version }} + {{ version.date | time_diff }} + {{ version.reference }} +
+ + + + +
+
+ {% 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 @@