From 4e98909f1120fe0f6ae06a225accd0a426cf9e76 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Sun, 12 Jul 2026 20:59:36 +0200 Subject: [PATCH] Add GitHub Releases modal to backoffice (#210) * Add GitHub Releases modal to backoffice Shows release notes (fetched from the GitHub API, cached for an hour, Markdown rendered via league/commonmark) from a top-right nav button available whether logged in or out, with the current version shown in the modal header. Also clears cache.app on container start so the release cache can't carry stale data across redeploys of the var/ Docker volume. * Render release notes markdown via twig/markdown-extra instead of manual CommonMark twig/extra-bundle already wires the markdown filter to league/commonmark automatically once twig/markdown-extra is installed, so the service no longer needs to instantiate and call CommonMarkConverter itself. * Explicitly disallow unsafe link protocols in rendered markdown twig/extra-bundle's default (allow_unsafe_links: true) permits javascript:/vbscript:/file:/data: URLs in rendered links, kept for backwards compatibility. Disable it explicitly since we now render release note markdown through this filter. * Autolink bare URLs in rendered release notes The Twig CommonMark converter only registers CommonMarkCoreExtension by default, which doesn't turn bare URLs into clickable links (unlike GitHub's own renderer). Register CommonMark's AutolinkExtension via the twig.markdown.league_extension tag so PR/compare URLs in release notes render as actual hyperlinks instead of plain text. * Address review feedback on GitHubReleasesService and tests - Extract the cache callback into a named fetchReleases() method instead of an inline closure with all the fetch/parse logic. - Simplify the User-Agent header to just "TijdVoorDeTest". - Add a 5s HTTP timeout so a slow GitHub API can't block the request. - Cache HTTP failures for only 60s instead of the full hour, so a brief GitHub outage doesn't suppress releases for as long. - Replace deprecated word-wrap with overflow-wrap in release-notes CSS. - Extract duplicated mock setup in ReleasesControllerTest into a helper. - Reword the "could not load" translation ("Kan" instead of "Kon"). * Address second round of CodeRabbit feedback - Escape raw HTML embedded in release-note markdown instead of passing it through, via twig_extra.commonmark.html_input: escape. - Don't cache GitHub API failures at all (set $save = false in the cache callback) instead of caching them for a short TTL, so the very next request retries immediately. - Sort releases by published_at descending before mapping, so the first entry (used for the current-version badge and default-expanded accordion item) is guaranteed to be the newest regardless of the order GitHub's API happens to return. --- assets/styles/backoffice.scss | 6 + composer.json | 3 + composer.lock | 496 +++++++++++++++++- config/packages/security.yaml | 1 + config/packages/twig_extra.yaml | 8 + config/reference.php | 2 +- frankenphp/docker-entrypoint.sh | 4 + .../Backoffice/ReleasesController.php | 25 + src/Service/GitHubReleasesService.php | 65 +++ templates/backoffice/nav.html.twig | 35 +- .../backoffice/releases/_frame.html.twig | 40 ++ .../Backoffice/ReleasesControllerTest.php | 55 ++ tests/Service/GitHubReleasesServiceTest.php | 95 ++++ translations/messages+intl-icu.nl.xliff | 16 + 14 files changed, 841 insertions(+), 10 deletions(-) create mode 100644 config/packages/twig_extra.yaml create mode 100644 src/Controller/Backoffice/ReleasesController.php create mode 100644 src/Service/GitHubReleasesService.php create mode 100644 templates/backoffice/releases/_frame.html.twig create mode 100644 tests/Controller/Backoffice/ReleasesControllerTest.php create mode 100644 tests/Service/GitHubReleasesServiceTest.php diff --git a/assets/styles/backoffice.scss b/assets/styles/backoffice.scss index cb69351..70f45b4 100644 --- a/assets/styles/backoffice.scss +++ b/assets/styles/backoffice.scss @@ -3,3 +3,9 @@ .col-result-md { width: 20%; } .modal-content > turbo-frame { display: contents; } + +.release-notes { + overflow-wrap: break-word; + + > :last-child { margin-bottom: 0; } +} diff --git a/composer.json b/composer.json index 322857e..8572c9f 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "doctrine/doctrine-bundle": "^3.2.2", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6.2", + "league/commonmark": "^2.7", "martin-georgiev/postgresql-for-doctrine": "^4.4", "phpdocumentor/reflection-docblock": "^6.0.3", "phpoffice/phpspreadsheet": "^5.5", @@ -27,6 +28,7 @@ "symfony/flex": "^2.11.0", "symfony/form": "8.1.*", "symfony/framework-bundle": "8.1.*", + "symfony/http-client": "8.1.*", "symfony/mailer": "8.1.*", "symfony/object-mapper": "8.1.*", "symfony/property-access": "8.1.*", @@ -48,6 +50,7 @@ "thecodingmachine/safe": "^3.4.0", "twig/extra-bundle": "^3.24.0", "twig/intl-extra": "^3.24.0", + "twig/markdown-extra": "^3.24.0", "twig/twig": "^3.27.1" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 5cbc0a7..53c3c3f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5", + "content-hash": "519e76dd9df3bca43c3e32e347c600e7", "packages": [ { "name": "composer/pcre", @@ -159,6 +159,81 @@ ], "time": "2025-08-20T19:15:30+00:00" }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, { "name": "doctrine/collections", "version": "2.6.0", @@ -1652,6 +1727,195 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "league/commonmark", + "version": "2.8.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-07-12T15:29:16+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, { "name": "maennchen/zipstream-php", "version": "3.2.2", @@ -1966,6 +2230,164 @@ ], "time": "2026-07-01T18:17:39+00:00" }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -8711,6 +9133,78 @@ ], "time": "2026-05-19T20:44:48+00:00" }, + { + "name": "twig/markdown-extra", + "version": "v3.28.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/markdown-extra.git", + "reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/5f7b27e41a382fc988fffa6e588d8f9d55b9d896", + "reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "twig/twig": "^3.13|^4.0" + }, + "require-dev": { + "erusev/parsedown": "dev-master as 1.x-dev", + "league/commonmark": "^2.7", + "league/html-to-markdown": "^4.8|^5.0", + "michelf/php-markdown": "^1.8|^2.0", + "symfony/phpunit-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Twig\\Extra\\Markdown\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Markdown", + "homepage": "https://twig.symfony.com", + "keywords": [ + "html", + "markdown", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/markdown-extra/tree/v3.28.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-06-05T19:47:22+00:00" + }, { "name": "twig/twig", "version": "v3.28.0", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b58f752..378d16d 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -30,6 +30,7 @@ security: access_control: - { path: ^/admin, roles: ROLE_ADMIN } + - { path: ^/backoffice/releases$, roles: PUBLIC_ACCESS } - { path: ^/backoffice, roles: IS_AUTHENTICATED } when@test: diff --git a/config/packages/twig_extra.yaml b/config/packages/twig_extra.yaml new file mode 100644 index 0000000..af379df --- /dev/null +++ b/config/packages/twig_extra.yaml @@ -0,0 +1,8 @@ +twig_extra: + commonmark: + allow_unsafe_links: false + html_input: escape + +services: + League\CommonMark\Extension\Autolink\AutolinkExtension: + tags: ['twig.markdown.league_extension'] diff --git a/config/reference.php b/config/reference.php index 2847703..5a712c0 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1271,7 +1271,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * enabled?: bool|Param, // Default: false * }, * markdown?: bool|array{ - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * }, * intl?: bool|array{ * enabled?: bool|Param, // Default: true diff --git a/frankenphp/docker-entrypoint.sh b/frankenphp/docker-entrypoint.sh index 07746b4..595f33a 100755 --- a/frankenphp/docker-entrypoint.sh +++ b/frankenphp/docker-entrypoint.sh @@ -33,6 +33,10 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then fi fi + # var/ is a Docker volume that survives redeploys, so cache.app (e.g. the GitHub releases cache) + # can carry stale entries from the previous image into the new one unless cleared here. + php bin/console cache:pool:clear cache.app + setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var diff --git a/src/Controller/Backoffice/ReleasesController.php b/src/Controller/Backoffice/ReleasesController.php new file mode 100644 index 0000000..b24fc37 --- /dev/null +++ b/src/Controller/Backoffice/ReleasesController.php @@ -0,0 +1,25 @@ +render('backoffice/releases/_frame.html.twig', [ + 'releases' => $this->gitHubReleasesService->getReleases(), + ]); + } +} diff --git a/src/Service/GitHubReleasesService.php b/src/Service/GitHubReleasesService.php new file mode 100644 index 0000000..a53045a --- /dev/null +++ b/src/Service/GitHubReleasesService.php @@ -0,0 +1,65 @@ + + */ + public function getReleases(): array + { + return $this->cache->get('github_releases', $this->fetchReleases(...)); + } + + /** @return list */ + private function fetchReleases(ItemInterface $item, bool &$save): array + { + try { + $response = $this->httpClient->request('GET', self::RELEASES_URL, [ + 'timeout' => 5, + 'headers' => [ + 'Accept' => 'application/vnd.github+json', + 'User-Agent' => 'TijdVoorDeTest', + ], + ]); + + /** @var list $releases */ + $releases = $response->toArray(); + } catch (ExceptionInterface) { + $save = false; + + return []; + } + + $item->expiresAfter(3600); + + usort($releases, static fn (array $a, array $b): int => ($b['published_at'] ?? '') <=> ($a['published_at'] ?? '')); + + return array_map(static fn (array $release): array => [ + 'tagName' => $release['tag_name'], + 'name' => $release['name'] ?: $release['tag_name'], + 'publishedAt' => $release['published_at'] ? new DateTimeImmutable($release['published_at']) : null, + 'body' => (string) $release['body'], + 'url' => $release['html_url'], + ], $releases); + } +} diff --git a/templates/backoffice/nav.html.twig b/templates/backoffice/nav.html.twig index 18fee18..b6c2e95 100644 --- a/templates/backoffice/nav.html.twig +++ b/templates/backoffice/nav.html.twig @@ -10,9 +10,9 @@ aria-label="Toggle navigation"> - {% if is_granted('IS_AUTHENTICATED') %} - diff --git a/templates/backoffice/releases/_frame.html.twig b/templates/backoffice/releases/_frame.html.twig new file mode 100644 index 0000000..b2890ca --- /dev/null +++ b/templates/backoffice/releases/_frame.html.twig @@ -0,0 +1,40 @@ + + + + diff --git a/tests/Controller/Backoffice/ReleasesControllerTest.php b/tests/Controller/Backoffice/ReleasesControllerTest.php new file mode 100644 index 0000000..030d969 --- /dev/null +++ b/tests/Controller/Backoffice/ReleasesControllerTest.php @@ -0,0 +1,55 @@ +loginAs('user2@example.org'); + $this->mockReleasesService(); + + $this->client->request(Request::METHOD_GET, '/backoffice/releases'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'v0.8.0'); + self::assertSelectorTextContains('body', 'Some release notes'); + self::assertSelectorTextContains('#releasesModalLabel', 'Huidige versie: v0.8.0'); + } + + public function testReleasesFrameIsAccessibleWithoutAuthentication(): void + { + $this->mockReleasesService(); + + $this->client->request(Request::METHOD_GET, '/backoffice/releases'); + + self::assertResponseIsSuccessful(); + } + + private function mockReleasesService(): void + { + $body = json_encode([ + [ + 'tag_name' => 'v0.8.0', + 'name' => 'v0.8.0', + 'published_at' => '2026-07-12T10:00:00Z', + 'body' => 'Some release notes', + 'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0', + ], + ], \JSON_THROW_ON_ERROR); + $httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]); + self::getContainer()->set(GitHubReleasesService::class, new GitHubReleasesService($httpClient, new ArrayAdapter())); + } +} diff --git a/tests/Service/GitHubReleasesServiceTest.php b/tests/Service/GitHubReleasesServiceTest.php new file mode 100644 index 0000000..3b6f762 --- /dev/null +++ b/tests/Service/GitHubReleasesServiceTest.php @@ -0,0 +1,95 @@ + 'v0.8.0', + 'name' => 'v0.8.0', + 'published_at' => '2026-07-12T10:00:00Z', + 'body' => "## Added\n- Something new", + 'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0', + ], + ], \JSON_THROW_ON_ERROR); + + $httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]); + $subject = new GitHubReleasesService($httpClient, new ArrayAdapter()); + + $releases = $subject->getReleases(); + + $this->assertEquals([ + 'tagName' => 'v0.8.0', + 'name' => 'v0.8.0', + 'publishedAt' => new DateTimeImmutable('2026-07-12T10:00:00Z'), + 'body' => "## Added\n- Something new", + 'url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0', + ], $releases[0]); + } + + public function testGetReleasesReturnsEmptyArrayOnHttpFailure(): void + { + $httpClient = new MockHttpClient(static fn (): MockResponse => new MockResponse('', ['http_code' => 500])); + $subject = new GitHubReleasesService($httpClient, new ArrayAdapter()); + + $this->assertSame([], $subject->getReleases()); + } + + public function testHttpFailureIsNotCached(): void + { + $requestCount = 0; + $httpClient = new MockHttpClient(static function () use (&$requestCount): MockResponse { + ++$requestCount; + + return new MockResponse('', ['http_code' => 500]); + }); + $subject = new GitHubReleasesService($httpClient, new ArrayAdapter()); + + $subject->getReleases(); + $subject->getReleases(); + + $this->assertSame(2, $requestCount); + } + + public function testGetReleasesSortsNewestFirst(): void + { + $body = json_encode([ + [ + 'tag_name' => 'v0.7.0', + 'name' => 'v0.7.0', + 'published_at' => '2026-07-10T10:00:00Z', + 'body' => 'Older release', + 'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.7.0', + ], + [ + 'tag_name' => 'v0.8.0', + 'name' => 'v0.8.0', + 'published_at' => '2026-07-12T10:00:00Z', + 'body' => 'Newer release', + 'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0', + ], + ], \JSON_THROW_ON_ERROR); + + $httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]); + $subject = new GitHubReleasesService($httpClient, new ArrayAdapter()); + + $releases = $subject->getReleases(); + + $this->assertSame('v0.8.0', $releases[0]['tagName']); + $this->assertSame('v0.7.0', $releases[1]['tagName']); + } +} diff --git a/translations/messages+intl-icu.nl.xliff b/translations/messages+intl-icu.nl.xliff index 76eb9ec..5936b45 100644 --- a/translations/messages+intl-icu.nl.xliff +++ b/translations/messages+intl-icu.nl.xliff @@ -241,6 +241,10 @@ Could not find candidate with name {name} in elimination. Kon geen kandidaat vinden met de naam {name} in de eliminatie + + Could not load releases from GitHub. + Kan releases niet laden van GitHub. + Create a season Maak een seizoen aan @@ -265,6 +269,10 @@ Current password Huidig wachtwoord + + Current version + Huidige versie + Cyan Cyaan @@ -745,6 +753,10 @@ Register Registreren + + Releases + Releases + Remember me Onthoud mij @@ -977,6 +989,10 @@ View Bekijken + + View on GitHub + Bekijk op GitHub + White Wit