diff --git a/.gitignore b/.gitignore
index 7838e8a..913a75c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,7 @@ package-lock.json
**/.DS_Store
public/uploads/
+
+# Generated by bin/generate-packages (daily cron); not tracked so it never conflicts on pull
+/public/packages.json
+/public/packages.json.tmp
diff --git a/bin/generate-packages b/bin/generate-packages
new file mode 100755
index 0000000..f19fc0d
--- /dev/null
+++ b/bin/generate-packages
@@ -0,0 +1,60 @@
+#!/usr/bin/env php
+> log/generate-packages.log 2>&1
+ *
+ * Exits non-zero without touching the data file when the run cannot be trusted, so the
+ * previously generated listing keeps serving.
+ */
+
+declare(strict_types=1);
+
+use Light\App\Service\PackageGenerator;
+
+chdir(__DIR__ . '/../');
+
+require 'vendor/autoload.php';
+
+$container = require 'config/container.php';
+$packageGenerator = $container->get(PackageGenerator::class);
+
+try {
+ $report = $packageGenerator->write();
+} catch (Throwable $exception) {
+ fwrite(STDERR, sprintf(
+ '[%s] generate-packages failed: %s%s',
+ date('c'),
+ $exception->getMessage(),
+ PHP_EOL
+ ));
+ exit(1);
+}
+
+printf(
+ '[%s] Done. %d package%s written to %s%s',
+ date('c'),
+ $report['written'],
+ $report['written'] === 1 ? '' : 's',
+ $packageGenerator->getDataFile(),
+ PHP_EOL
+);
+
+if ($report['skipped'] !== []) {
+ printf('Skipped by ignoreRepos: %s%s', implode(', ', $report['skipped']), PHP_EOL);
+}
+
+if ($report['ignoredMisses'] !== []) {
+ printf(
+ 'WARNING: ignoreRepos entries matched nothing (renamed or deleted?): %s%s',
+ implode(', ', $report['ignoredMisses']),
+ PHP_EOL
+ );
+}
+
+foreach ($report['warnings'] as $warning) {
+ printf('WARNING: %s%s', $warning, PHP_EOL);
+}
diff --git a/composer.json b/composer.json
index 36215ad..accf000 100644
--- a/composer.json
+++ b/composer.json
@@ -27,6 +27,7 @@
},
"require": {
"php": "~8.5.0",
+ "ext-curl": "*",
"ext-dom": "*",
"doctrine/data-fixtures": "^2.2",
"doctrine/doctrine-fixtures-bundle": "^4.3",
diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist
index 69ee230..40881f0 100644
--- a/config/autoload/local.php.dist
+++ b/config/autoload/local.php.dist
@@ -59,13 +59,24 @@ return [
'feed' => [
'path' => realpath(__DIR__ . '/../../public/feed.xml'),
],
- 'routes' => [
+ /**
+ * Credentials for `bin/generate-packages`. Reading public repositories needs no token
+ * scopes at all; without a token the generator still runs, at a lower rate limit.
+ * Non-credential settings (ignore list, output path) live in `packages.global.php`.
+ */
+ 'github' => [
+ 'userAgent' => 'dotkernel.com',
+ 'authBearer' => '',
+ 'org' => 'dotkernel',
+ ],
+ // `dotkernel-packages-oss-lifecycle` is intentionally absent: it has a dedicated handler
+ // and is routed in Light\App\RoutesDelegator. Re-adding it here registers the path twice.
+ 'routes' => [
'page' => [
- 'contact' => 'contact',
- 'dotkernel-packages-oss-lifecycle' => 'dotkernel-packages-oss-lifecycle',
+ 'contact' => 'contact',
],
],
- 'twig' => [
+ 'twig' => [
'globals' => [
'app' => $app,
],
diff --git a/config/autoload/packages.global.php b/config/autoload/packages.global.php
new file mode 100644
index 0000000..9b6036b
--- /dev/null
+++ b/config/autoload/packages.global.php
@@ -0,0 +1,77 @@
+ [
+ /**
+ * ignored repositories
+ * matched on the bare repository name (case-insensitive)
+ */
+ 'ignoreRepos' => [
+ '.github',
+ 'admin',
+ 'admin-documentation',
+ 'apidemia.com',
+ 'api',
+ 'api-documentation',
+ 'app-packages',
+ 'api-tools-migration',
+ 'core',
+ 'development',
+ 'documentation',
+ 'documentation-theme',
+ 'dotboost',
+ 'dotkernel',
+ 'dotkernel.com',
+ 'dotkernel.github.io',
+ 'dotkernel.org',
+ 'dotkernel-v1',
+ 'dot-opensearch',
+ 'dot-privy',
+ 'dot-queue',
+ 'dot-sso-entra',
+ 'frontend',
+ 'frontend-documentation',
+ 'fullview',
+ 'headless-documentation',
+ 'light',
+ 'light-documentation',
+ 'mezzio-hal',
+ 'ng-admin',
+ 'ngx-admin',
+ 'php-llm-examples',
+ 'pingu',
+ 'plugin-mail-transporter',
+ 'pong',
+ 'presentation',
+ 'queue',
+ 'queue-documentation',
+ 'template',
+ 'template-expressive',
+ 'tutorial-101',
+ 'vue',
+ 'workflow-automatic-releases',
+ 'workflow-continuous-integration',
+ 'workshops',
+ 'ws-entities-collections',
+ 'zend',
+ 'zend-expressive-hal',
+ 'zf1',
+ ],
+ 'dataFile' => __DIR__ . '/../../public/packages.json',
+ 'includeArchived' => true,
+ 'timeout' => 10,
+ 'connectTimeout' => 5,
+ ],
+];
diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php
index 8798e27..5fae52c 100644
--- a/src/App/src/ConfigProvider.php
+++ b/src/App/src/ConfigProvider.php
@@ -16,11 +16,18 @@
use Light\App\Factory\GetFeedViewHandlerFactory;
use Light\App\Factory\GetIndexViewHandlerFactory;
use Light\App\Factory\GetMarkdownArticleHandlerFactory;
+use Light\App\Factory\GetPackagesViewHandlerFactory;
+use Light\App\Factory\GitHubClientFactory;
+use Light\App\Factory\PackageGeneratorFactory;
use Light\App\Handler\GetFeedViewHandler;
use Light\App\Handler\GetIndexViewHandler;
use Light\App\Handler\GetMarkdownArticleHandler;
+use Light\App\Handler\GetPackagesViewHandler;
use Light\App\Resolver\EntityListenerResolver;
use Light\App\Service\FeedGenerator;
+use Light\App\Service\GitHubClient;
+use Light\App\Service\GitHubClientInterface;
+use Light\App\Service\PackageGenerator;
use Mezzio\Application;
use Roave\PsrContainerDoctrine\EntityManagerFactory;
use Symfony\Component\Cache\Adapter\AdapterInterface;
@@ -121,11 +128,15 @@ public function getDependencies(): array
GetIndexViewHandler::class => GetIndexViewHandlerFactory::class,
GetFeedViewHandler::class => GetFeedViewHandlerFactory::class,
GetMarkdownArticleHandler::class => GetMarkdownArticleHandlerFactory::class,
+ GetPackagesViewHandler::class => GetPackagesViewHandlerFactory::class,
FeedGenerator::class => FeedGeneratorFactory::class,
+ GitHubClient::class => GitHubClientFactory::class,
+ PackageGenerator::class => PackageGeneratorFactory::class,
],
'aliases' => [
EntityManager::class => 'doctrine.entity_manager.orm_default',
EntityManagerInterface::class => 'doctrine.entity_manager.orm_default',
+ GitHubClientInterface::class => GitHubClient::class,
],
];
}
diff --git a/src/App/src/Factory/GetPackagesViewHandlerFactory.php b/src/App/src/Factory/GetPackagesViewHandlerFactory.php
new file mode 100644
index 0000000..e6246ac
--- /dev/null
+++ b/src/App/src/Factory/GetPackagesViewHandlerFactory.php
@@ -0,0 +1,34 @@
+get(TemplateRendererInterface::class);
+ assert($template instanceof TemplateRendererInterface);
+
+ $categoryRepository = $container->get(CategoryRepository::class);
+ assert($categoryRepository instanceof CategoryRepository);
+
+ $postRepository = $container->get(PostRepository::class);
+ assert($postRepository instanceof PostRepository);
+
+ $packageGenerator = $container->get(PackageGenerator::class);
+ assert($packageGenerator instanceof PackageGenerator);
+
+ return new GetPackagesViewHandler($template, $categoryRepository, $postRepository, $packageGenerator);
+ }
+}
diff --git a/src/App/src/Factory/GitHubClientFactory.php b/src/App/src/Factory/GitHubClientFactory.php
new file mode 100644
index 0000000..55fe722
--- /dev/null
+++ b/src/App/src/Factory/GitHubClientFactory.php
@@ -0,0 +1,36 @@
+get('config');
+ if (! is_array($config)) {
+ $config = [];
+ }
+ // the token is in `config/autoload/local.php`
+ // if unauthenticated, the client has a lower call limit
+ $github = isset($config['github']) && is_array($config['github'])
+ ? $config['github']
+ : [];
+ $packages = isset($config['packages']) && is_array($config['packages'])
+ ? $config['packages']
+ : [];
+
+ return new GitHubClient(
+ (string) ($github['authBearer'] ?? ''),
+ (string) ($github['userAgent'] ?? 'dotkernel.com'),
+ (int) ($packages['timeout'] ?? 10),
+ (int) ($packages['connectTimeout'] ?? 5),
+ );
+ }
+}
diff --git a/src/App/src/Factory/PackageGeneratorFactory.php b/src/App/src/Factory/PackageGeneratorFactory.php
new file mode 100644
index 0000000..4ee5b26
--- /dev/null
+++ b/src/App/src/Factory/PackageGeneratorFactory.php
@@ -0,0 +1,50 @@
+get(GitHubClientInterface::class);
+ assert($client instanceof GitHubClientInterface);
+
+ $config = $container->get('config');
+ if (! is_array($config)) {
+ $config = [];
+ }
+ $github = isset($config['github']) && is_array($config['github'])
+ ? $config['github']
+ : [];
+ $packages = isset($config['packages']) && is_array($config['packages'])
+ ? $config['packages']
+ : [];
+
+ $ignoreRepos = [];
+ if (isset($packages['ignoreRepos']) && is_array($packages['ignoreRepos'])) {
+ foreach ($packages['ignoreRepos'] as $repository) {
+ if (is_scalar($repository)) {
+ $ignoreRepos[] = (string) $repository;
+ }
+ }
+ }
+
+ return new PackageGenerator(
+ $client,
+ (string) ($packages['dataFile'] ?? 'data/packages.json'),
+ (string) ($github['org'] ?? 'dotkernel'),
+ $ignoreRepos,
+ (bool) ($packages['includeArchived'] ?? true),
+ );
+ }
+}
diff --git a/src/App/src/Handler/GetPackagesViewHandler.php b/src/App/src/Handler/GetPackagesViewHandler.php
new file mode 100644
index 0000000..5188d5b
--- /dev/null
+++ b/src/App/src/Handler/GetPackagesViewHandler.php
@@ -0,0 +1,55 @@
+packageGenerator->read();
+ if (! is_array($data)) {
+ $data = [];
+ }
+ $packages = isset($data['packages']) && is_array($data['packages'])
+ ? $data['packages']
+ : [];
+ $generatedAt = isset($data['generated_at']) && is_string($data['generated_at'])
+ ? $data['generated_at']
+ : null;
+
+ return new HtmlResponse(
+ $this->template->render(self::TEMPLATE, [
+ 'posts' => $this->postRepository->getRecentPosts(3),
+ 'categories' => $this->categoryRepository->getCategories(),
+ 'packages' => $packages,
+ 'generatedAt' => $generatedAt,
+ ])
+ );
+ }
+}
diff --git a/src/App/src/RoutesDelegator.php b/src/App/src/RoutesDelegator.php
index f5935f4..613b0c3 100644
--- a/src/App/src/RoutesDelegator.php
+++ b/src/App/src/RoutesDelegator.php
@@ -8,6 +8,7 @@
use Light\App\Handler\GetFeedViewHandler;
use Light\App\Handler\GetIndexViewHandler;
use Light\App\Handler\GetMarkdownArticleHandler;
+use Light\App\Handler\GetPackagesViewHandler;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
@@ -23,6 +24,14 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal
$app->get('/feed/', [GetFeedViewHandler::class], 'app::feed');
$app->get('/{categorySlug}/{slug}.md', [GetMarkdownArticleHandler::class], 'app::markdown-article');
+ // Route name kept as `page::…` because `@layout/default.html.twig` links it by name.
+ // The matching entry must stay out of `routes.page` in local.php to avoid a duplicate.
+ $app->get(
+ '/dotkernel-packages-oss-lifecycle/',
+ [GetPackagesViewHandler::class],
+ GetPackagesViewHandler::TEMPLATE
+ );
+
$app->get('/{first}', function ($request) {
$uri = $request->getUri();
return new RedirectResponse((string) $uri . '/', 301);
diff --git a/src/App/src/Service/GitHubClient.php b/src/App/src/Service/GitHubClient.php
new file mode 100644
index 0000000..4ac47d6
--- /dev/null
+++ b/src/App/src/Service/GitHubClient.php
@@ -0,0 +1,220 @@
+}
+ */
+class GitHubClient implements GitHubClientInterface
+{
+ private const string API_ROOT = 'https://api.github.com';
+ private const string API_VERSION = '2022-11-28';
+ private const string DEFAULT_USER_AGENT = 'dotkernel.com';
+
+ /**
+ * cURL rejects an empty user agent, and GitHub rejects requests without one, so an empty
+ * configured value falls back to the default rather than failing every request.
+ *
+ * @var non-empty-string
+ */
+ private readonly string $userAgent;
+
+ public function __construct(
+ private readonly string $token,
+ string $userAgent,
+ private readonly int $timeout,
+ private readonly int $connectTimeout,
+ ) {
+ $this->userAgent = $userAgent === '' ? self::DEFAULT_USER_AGENT : $userAgent;
+ }
+
+ /**
+ * @param non-empty-string $path
+ */
+ public function get(string $path, string $accept = self::ACCEPT_JSON): ?string
+ {
+ $response = $this->request($this->absoluteUrl($path), $accept);
+
+ if ($response['status'] === StatusCodeInterface::STATUS_NOT_FOUND) {
+ return null;
+ }
+
+ $this->assertOk($response['status'], $path);
+
+ return $response['body'];
+ }
+
+ /**
+ * @param non-empty-string $path
+ * @return list>
+ */
+ public function getAllPages(string $path): array
+ {
+ $url = $this->absoluteUrl($path);
+ $items = [];
+
+ while ($url !== null) {
+ $response = $this->request($url, self::ACCEPT_JSON);
+ $this->assertOk($response['status'], $url);
+
+ $decoded = json_decode($response['body'], true);
+ if (! is_array($decoded)) {
+ throw new RuntimeException(sprintf('Expected a JSON array from %s.', $url));
+ }
+
+ foreach ($decoded as $item) {
+ if (is_array($item)) {
+ $items[] = $item;
+ }
+ }
+
+ $url = $response['links']['next'] ?? null;
+ }
+
+ return $items;
+ }
+
+ /**
+ * @param non-empty-string $url
+ * @return ResponseData
+ */
+ private function request(string $url, string $accept): array
+ {
+ $handle = curl_init();
+ if (! $handle instanceof CurlHandle) {
+ throw new RuntimeException('Unable to initialise a cURL handle.');
+ }
+
+ $links = [];
+
+ curl_setopt_array($handle, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 3,
+ CURLOPT_TIMEOUT => $this->timeout,
+ CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
+ CURLOPT_USERAGENT => $this->userAgent,
+ CURLOPT_HTTPHEADER => $this->headers($accept),
+ CURLOPT_HEADERFUNCTION => function (CurlHandle $curlHandle, string $header) use (&$links): int {
+ $parts = explode(':', $header, 2);
+ if (isset($parts[1]) && strtolower(trim($parts[0])) === 'link') {
+ $links = $this->parseLinkHeader(trim($parts[1]));
+ }
+
+ return strlen($header);
+ },
+ ]);
+
+ $body = curl_exec($handle);
+ $error = curl_error($handle);
+ $status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
+
+ if ($body === false) {
+ throw new RuntimeException(sprintf('Request to %s failed: %s', $url, $error));
+ }
+
+ return [
+ 'status' => (int) $status,
+ 'body' => (string) $body,
+ 'links' => $links,
+ ];
+ }
+
+ /**
+ * Parses `; rel="next", ; rel="last"` into a rel => url map.
+ *
+ * @return array
+ */
+ private function parseLinkHeader(string $value): array
+ {
+ $links = [];
+
+ foreach (explode(',', $value) as $part) {
+ if (preg_match('/<([^>]+)>\s*;\s*rel="([^"]+)"/', trim($part), $matches) !== 1) {
+ continue;
+ }
+
+ $links[$matches[2]] = $matches[1];
+ }
+
+ return $links;
+ }
+
+ /**
+ * @return list
+ */
+ private function headers(string $accept): array
+ {
+ $headers = [
+ 'Accept: ' . $accept,
+ 'X-GitHub-Api-Version: ' . self::API_VERSION,
+ ];
+
+ if ($this->token !== '') {
+ $headers[] = 'Authorization: Bearer ' . $this->token;
+ }
+
+ return $headers;
+ }
+
+ /**
+ * @param non-empty-string $path
+ * @return non-empty-string
+ */
+ private function absoluteUrl(string $path): string
+ {
+ if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
+ return $path;
+ }
+
+ return self::API_ROOT . $path;
+ }
+
+ private function assertOk(int $status, string $url): void
+ {
+ if ($status === StatusCodeInterface::STATUS_OK) {
+ return;
+ }
+
+ throw new RuntimeException(sprintf('GitHub returned HTTP %d for %s.', $status, $url));
+ }
+}
diff --git a/src/App/src/Service/GitHubClientInterface.php b/src/App/src/Service/GitHubClientInterface.php
new file mode 100644
index 0000000..e51c51e
--- /dev/null
+++ b/src/App/src/Service/GitHubClientInterface.php
@@ -0,0 +1,31 @@
+>
+ * @throws RuntimeException On transport failure or an unexpected response status.
+ */
+ public function getAllPages(string $path): array;
+}
diff --git a/src/App/src/Service/PackageGenerator.php b/src/App/src/Service/PackageGenerator.php
new file mode 100644
index 0000000..3281ea3
--- /dev/null
+++ b/src/App/src/Service/PackageGenerator.php
@@ -0,0 +1,320 @@
+,
+ * ignoredMisses: list,
+ * warnings: list,
+ * }
+ */
+readonly class PackageGenerator
+{
+ /**
+ * Display order for the generated listing. Anything unrecognised sorts last.
+ */
+ private const array LIFECYCLE_ORDER = [
+ 'active' => 0,
+ 'maintenance' => 1,
+ 'security-only' => 2,
+ 'archived' => 3,
+ ];
+
+ /**
+ * Fraction of failed requests above which the run is abandoned rather than writing a
+ * partial listing over a known-good one.
+ */
+ private const float FAILURE_THRESHOLD = 0.2;
+
+ private const string GITHUB_ROOT = 'https://github.com/';
+
+ /**
+ * @param list $ignoreRepos
+ */
+ public function __construct(
+ private GitHubClientInterface $client,
+ private string $dataFile,
+ private string $org,
+ private array $ignoreRepos,
+ private bool $includeArchived,
+ ) {
+ }
+
+ public function getDataFile(): string
+ {
+ return $this->dataFile;
+ }
+
+ /**
+ * Queries the organisation and rewrites the data file.
+ *
+ * @return ReportData
+ * @throws RuntimeException When the listing cannot be retrieved, too many per-repository
+ * requests fail, or the data file cannot be written.
+ * @throws JsonException
+ */
+ public function write(): array
+ {
+ $ignore = $this->buildIgnoreLookup();
+ $ignoreHits = [];
+ $packages = [];
+ $skipped = [];
+ $warnings = [];
+ $attempts = 0;
+ $failures = 0;
+
+ $repositories = $this->client->getAllPages(
+ sprintf('/orgs/%s/repos?per_page=100&type=public', $this->org)
+ );
+
+ foreach ($repositories as $repository) {
+ $name = isset($repository['name']) && is_string($repository['name'])
+ ? trim($repository['name'])
+ : '';
+
+ if ($name === '') {
+ continue;
+ }
+
+ $key = strtolower($name);
+ if (array_key_exists($key, $ignore)) {
+ $ignoreHits[$key] = true;
+ $skipped[] = $name;
+ continue;
+ }
+
+ $archived = (bool) ($repository['archived'] ?? false);
+ if ($archived && ! $this->includeArchived) {
+ continue;
+ }
+
+ $attempts++;
+ try {
+ $metadata = $this->client->get(
+ sprintf('/repos/%s/%s/contents/OSSMETADATA', $this->org, $name),
+ GitHubClientInterface::ACCEPT_RAW
+ );
+ } catch (RuntimeException $exception) {
+ $failures++;
+ $warnings[] = sprintf('%s: could not read OSSMETADATA (%s)', $name, $exception->getMessage());
+ continue;
+ }
+
+ if ($metadata === null) {
+ // No OSSMETADATA: not a published package.
+ continue;
+ }
+
+ $lifecycle = $this->parseLifecycle($metadata);
+ if ($lifecycle === null) {
+ $warnings[] = sprintf('%s: OSSMETADATA present but no osslifecycle value found, skipped', $name);
+ continue;
+ }
+
+ $php = null;
+ $attempts++;
+ try {
+ $composer = $this->client->get(
+ sprintf('/repos/%s/%s/contents/composer.json', $this->org, $name),
+ GitHubClientInterface::ACCEPT_RAW
+ );
+ if ($composer !== null) {
+ $php = $this->parsePhpConstraint($composer);
+ }
+ } catch (RuntimeException $exception) {
+ $failures++;
+ $warnings[] = sprintf('%s: could not read composer.json (%s)', $name, $exception->getMessage());
+ }
+
+ $packages[] = [
+ 'name' => $name,
+ 'url' => self::GITHUB_ROOT . $this->org . '/' . $name,
+ 'lifecycle' => $lifecycle,
+ 'php' => $php,
+ 'archived' => $archived,
+ ];
+ }
+
+ if ($attempts > 0 && $failures / $attempts > self::FAILURE_THRESHOLD) {
+ throw new RuntimeException(sprintf(
+ 'Aborting without writing: %d of %d requests failed, which exceeds the %d%% threshold.',
+ $failures,
+ $attempts,
+ (int) (self::FAILURE_THRESHOLD * 100)
+ ));
+ }
+
+ usort($packages, static function (array $left, array $right): int {
+ $leftRank = self::LIFECYCLE_ORDER[$left['lifecycle']] ?? PHP_INT_MAX;
+ $rightRank = self::LIFECYCLE_ORDER[$right['lifecycle']] ?? PHP_INT_MAX;
+
+ return [$leftRank, $left['name']] <=> [$rightRank, $right['name']];
+ });
+
+ $this->save($packages);
+
+ $ignoredMisses = [];
+ foreach (array_keys($ignore) as $ignored) {
+ if (! array_key_exists($ignored, $ignoreHits)) {
+ $ignoredMisses[] = $ignored;
+ }
+ }
+
+ return [
+ 'written' => count($packages),
+ 'skipped' => $skipped,
+ 'ignoredMisses' => $ignoredMisses,
+ 'warnings' => $warnings,
+ ];
+ }
+
+ /**
+ * Returns the generated listing, or null when it is missing or unreadable.
+ *
+ * @return array|null
+ */
+ public function read(): ?array
+ {
+ if (! is_file($this->dataFile)) {
+ return null;
+ }
+
+ $contents = file_get_contents($this->dataFile);
+ if ($contents === false || trim($contents) === '') {
+ return null;
+ }
+
+ $decoded = json_decode($contents, true);
+ if (! is_array($decoded) || ! isset($decoded['packages']) || ! is_array($decoded['packages'])) {
+ return null;
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * @param list $packages
+ * @throws RuntimeException
+ * @throws JsonException
+ */
+ private function save(array $packages): void
+ {
+ $payload = [
+ 'generated_at' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
+ 'org' => $this->org,
+ 'packages' => $packages,
+ ];
+
+ $json = json_encode(
+ $payload,
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
+ );
+
+ $directory = dirname($this->dataFile);
+ if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
+ throw new RuntimeException(sprintf('Unable to create directory %s.', $directory));
+ }
+
+ // Write to a sibling file and rename, so a crash mid-write cannot leave truncated JSON
+ // behind for the request handler to read.
+ $temporaryFile = $this->dataFile . '.tmp';
+ if (file_put_contents($temporaryFile, $json) === false) {
+ throw new RuntimeException(sprintf('Unable to write %s.', $temporaryFile));
+ }
+
+ if (! rename($temporaryFile, $this->dataFile)) {
+ unlink($temporaryFile);
+ throw new RuntimeException(sprintf('Unable to move %s into place.', $temporaryFile));
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function buildIgnoreLookup(): array
+ {
+ $names = [];
+ foreach ($this->ignoreRepos as $repository) {
+ $normalised = strtolower(trim($repository));
+ if ($normalised !== '') {
+ $names[] = $normalised;
+ }
+ }
+
+ return array_flip($names);
+ }
+
+ private function parseLifecycle(string $contents): ?string
+ {
+ if (preg_match('/osslifecycle\s*=\s*([^\s#]+)/i', $contents, $matches) !== 1) {
+ return null;
+ }
+
+ $lifecycle = strtolower(trim($matches[1]));
+
+ return $lifecycle === '' ? null : $lifecycle;
+ }
+
+ private function parsePhpConstraint(string $contents): ?string
+ {
+ $decoded = json_decode($contents, true);
+ if (! is_array($decoded) || ! isset($decoded['require']) || ! is_array($decoded['require'])) {
+ return null;
+ }
+
+ $php = $decoded['require']['php'] ?? null;
+ if (! is_string($php) || trim($php) === '') {
+ return null;
+ }
+
+ return trim($php);
+ }
+}
diff --git a/src/App/templates/app/index.html.twig b/src/App/templates/app/index.html.twig
index eaa0267..ac88805 100644
--- a/src/App/templates/app/index.html.twig
+++ b/src/App/templates/app/index.html.twig
@@ -93,8 +93,6 @@
Three deployables that scale independently and never disagree about the domain.
-
-
Shared domain layer
Core\App Core\Admin Core\User
diff --git a/src/Blog/templates/page/dotkernel-packages-oss-lifecycle.html.twig b/src/Blog/templates/page/dotkernel-packages-oss-lifecycle.html.twig
index 685eec8..ff65429 100644
--- a/src/Blog/templates/page/dotkernel-packages-oss-lifecycle.html.twig
+++ b/src/Blog/templates/page/dotkernel-packages-oss-lifecycle.html.twig
@@ -11,6 +11,17 @@
{% endblock %}
{% block content %}
+ {#
+ Rendered from data/packages.json, rebuilt daily by bin/generate-packages.
+ The lifecycle value comes from each repository's OSSMETADATA file.
+ #}
+ {% set lifecycleMeta = {
+ active: {label: 'Active', accent: 'var(--teal)'},
+ maintenance: {label: 'Maintenance', accent: 'var(--brand-red)'},
+ 'security-only': {label: 'Security-only', accent: 'var(--amber)'},
+ archived: {label: 'Archived', accent: 'var(--periwinkle)'},
+ } %}
+
@@ -19,642 +30,30 @@
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Active
dot-twigrenderer
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Active
dot-authorization
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Security-only
dot-annotated-services
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Active
dot-flashmessenger
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Active
dot-errorhandler
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Active
dot-mail-outlook
-
-
-

-
-
-
-
-
-
-
Active
dot-rbac-route-guard
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Active
dot-authentication
-
-
-

-
-
-
-
-
-
-
Active
dot-user-agent-sniffer
-
-
-

-
-
-
-
-
-
-
Active
dot-auth-social
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Active
dot-response-header
-
-
-

-
-
-
-
-
-
-
Active
dot-dependency-injection
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Active
dot-data-fixtures
-
-
-

-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
Security-only
dot-doctrine-metadata
-
-
-

-
-
-
-
-
-
-
Security-only
dot-mapper
-
-
-

-
-
-
-
-
-
-
Security-only
dot-paginator
-
-
-

-
-
-
-
-
-
-
Security-only
dot-validator
-
-
-

-
-
-
-
-
-
-
Security-only
dot-inputfilter
-
-
-

-
-
-
-
-
-
-
Maintenance
dot-authentication-service
-
-
-

-
-
-
-
-
-
-
Security-only
dot-hydrator
-
-
-

-
-
-
-
-
-
-
Security-only
dot-authentication-web
-
-
-

-
-
-
-
-
-
-
Security-only
dot-controller-plugin-mail
-
-
-

-
-
-
-
-
-
-
Security-only
dot-controller-plugin-session
-
-
-

-
-
-
-
-
-
-
Security-only
dot-user
-
-
-

-
-
-
-
-
-
-
Security-only
dot-controller-plugin-flashmessenger
-
-
-

-
-
-
-
-
-
-
Security-only
dot-filter
-
-
-

-
-
-
-
-
-
-
Security-only
dot-controller-plugin-forms
-
-
-

-
-
-
-
-
-
-
Security-only
dot-controller-plugin-authorization
-
-
-

-
-
-
-
-
-
-
Security-only
dot-controller-plugin-authentication
-
-
-

-
-
-
-
-
-
-
-

-
-
-
+ {% for package in packages %}
+ {% set meta = lifecycleMeta[package.lifecycle] ?? {label: package.lifecycle|capitalize, accent: 'var(--periwinkle)'} %}
+
+
+
{{ meta.label }}
{{ package.name }}
+
+
+

+ {% if package.archived %}
+
Archived on GitHub
+ {% endif %}
+
+
+
+ {% else %}
+
The package listing is not available right now. Please check back shortly.
+ {% endfor %}
+
+ {% if generatedAt %}
+ Last updated {{ generatedAt|date('M d, Y H:i T') }}
+ {% endif %}
diff --git a/test/Unit/App/Factory/GetPackagesViewHandlerFactoryTest.php b/test/Unit/App/Factory/GetPackagesViewHandlerFactoryTest.php
new file mode 100644
index 0000000..64bc6b6
--- /dev/null
+++ b/test/Unit/App/Factory/GetPackagesViewHandlerFactoryTest.php
@@ -0,0 +1,62 @@
+ $this->createStub(TemplateRendererInterface::class),
+ CategoryRepository::class => $this->createStub(CategoryRepository::class),
+ PostRepository::class => $this->createStub(PostRepository::class),
+ PackageGenerator::class => $this->createStub(PackageGenerator::class),
+ ];
+
+ $container = $this->createStub(ContainerInterface::class);
+ $container
+ ->method('get')
+ ->willReturnCallback(function (string $id) use ($dependencies): object {
+ $this->assertTrue(
+ array_key_exists($id, $dependencies),
+ sprintf('The factory asked the container for an unexpected service: %s', $id)
+ );
+
+ return $dependencies[$id];
+ });
+
+ $handler = (new GetPackagesViewHandlerFactory())($container);
+
+ $this->assertSame($dependencies[TemplateRendererInterface::class], $this->readProperty($handler, 'template'));
+ $this->assertSame(
+ $dependencies[CategoryRepository::class],
+ $this->readProperty($handler, 'categoryRepository')
+ );
+ $this->assertSame($dependencies[PostRepository::class], $this->readProperty($handler, 'postRepository'));
+ $this->assertSame($dependencies[PackageGenerator::class], $this->readProperty($handler, 'packageGenerator'));
+ }
+
+ private function readProperty(GetPackagesViewHandler $handler, string $name): mixed
+ {
+ return (new ReflectionProperty(GetPackagesViewHandler::class, $name))->getValue($handler);
+ }
+}
diff --git a/test/Unit/App/Factory/GitHubClientFactoryTest.php b/test/Unit/App/Factory/GitHubClientFactoryTest.php
new file mode 100644
index 0000000..d7f3a98
--- /dev/null
+++ b/test/Unit/App/Factory/GitHubClientFactoryTest.php
@@ -0,0 +1,102 @@
+createContainer([
+ 'github' => [
+ 'authBearer' => 'gh-token',
+ 'userAgent' => 'dotkernel.com-test',
+ ],
+ 'packages' => [
+ 'timeout' => 30,
+ 'connectTimeout' => 15,
+ ],
+ ]));
+
+ $this->assertSame('gh-token', $this->readProperty($client, 'token'));
+ $this->assertSame('dotkernel.com-test', $this->readProperty($client, 'userAgent'));
+ $this->assertSame(30, $this->readProperty($client, 'timeout'));
+ $this->assertSame(15, $this->readProperty($client, 'connectTimeout'));
+ }
+
+ /**
+ * Values arrive from config, so they are not guaranteed to already be the right type.
+ *
+ * @throws Exception
+ */
+ public function testInvokeCastsTheConfiguredValues(): void
+ {
+ $client = (new GitHubClientFactory())($this->createContainer([
+ 'github' => ['authBearer' => 12345, 'userAgent' => 678],
+ 'packages' => ['timeout' => '30', 'connectTimeout' => '15'],
+ ]));
+
+ $this->assertSame('12345', $this->readProperty($client, 'token'));
+ $this->assertSame('678', $this->readProperty($client, 'userAgent'));
+ $this->assertSame(30, $this->readProperty($client, 'timeout'));
+ $this->assertSame(15, $this->readProperty($client, 'connectTimeout'));
+ }
+
+ /**
+ * A machine without credentials still gets a usable, unauthenticated client.
+ *
+ * @param mixed $config
+ * @throws Exception
+ */
+ #[DataProvider('incompleteConfigProvider')]
+ public function testInvokeFallsBackToDefaults($config): void
+ {
+ $client = (new GitHubClientFactory())($this->createContainer($config));
+
+ $this->assertSame('', $this->readProperty($client, 'token'));
+ $this->assertSame('dotkernel.com', $this->readProperty($client, 'userAgent'));
+ $this->assertSame(10, $this->readProperty($client, 'timeout'));
+ $this->assertSame(5, $this->readProperty($client, 'connectTimeout'));
+ }
+
+ /**
+ * @return array
+ */
+ public static function incompleteConfigProvider(): array
+ {
+ return [
+ 'config is not an array' => ['not an array'],
+ 'config is empty' => [[]],
+ 'sections are not arrays' => [['github' => 'nope', 'packages' => 'nope']],
+ 'sections are empty' => [['github' => [], 'packages' => []]],
+ ];
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createContainer(mixed $config): ContainerInterface
+ {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->with('config')->willReturn($config);
+
+ return $container;
+ }
+
+ private function readProperty(GitHubClient $client, string $name): mixed
+ {
+ return (new ReflectionProperty(GitHubClient::class, $name))->getValue($client);
+ }
+}
diff --git a/test/Unit/App/Factory/PackageGeneratorFactoryTest.php b/test/Unit/App/Factory/PackageGeneratorFactoryTest.php
new file mode 100644
index 0000000..09581ea
--- /dev/null
+++ b/test/Unit/App/Factory/PackageGeneratorFactoryTest.php
@@ -0,0 +1,116 @@
+createContainer([
+ 'github' => ['org' => 'acme'],
+ 'packages' => [
+ 'dataFile' => '/tmp/acme-packages.json',
+ 'ignoreRepos' => ['acme.com'],
+ 'includeArchived' => false,
+ ],
+ ]));
+
+ $this->assertSame('/tmp/acme-packages.json', $generator->getDataFile());
+ $this->assertSame('acme', $this->readProperty($generator, 'org'));
+ $this->assertSame(['acme.com'], $this->readProperty($generator, 'ignoreRepos'));
+ $this->assertFalse($this->readProperty($generator, 'includeArchived'));
+ }
+
+ /**
+ * @param mixed $config
+ * @throws Exception
+ */
+ #[DataProvider('incompleteConfigProvider')]
+ public function testInvokeFallsBackToDefaults($config): void
+ {
+ $generator = (new PackageGeneratorFactory())($this->createContainer($config));
+
+ $this->assertSame('data/packages.json', $generator->getDataFile());
+ $this->assertSame('dotkernel', $this->readProperty($generator, 'org'));
+ $this->assertSame([], $this->readProperty($generator, 'ignoreRepos'));
+ $this->assertTrue($this->readProperty($generator, 'includeArchived'));
+ }
+
+ /**
+ * @return array
+ */
+ public static function incompleteConfigProvider(): array
+ {
+ return [
+ 'config is not an array' => ['not an array'],
+ 'config is empty' => [[]],
+ 'sections are not arrays' => [['github' => 'nope', 'packages' => 'nope']],
+ 'sections are empty' => [['github' => [], 'packages' => []]],
+ 'ignoreRepos is not an array' => [['packages' => ['ignoreRepos' => 'dotkernel.com']]],
+ ];
+ }
+
+ /**
+ * Config is not guaranteed to hold a clean list of strings.
+ *
+ * @throws Exception
+ */
+ public function testInvokeKeepsOnlyScalarIgnoredRepositoriesAsStrings(): void
+ {
+ $generator = (new PackageGeneratorFactory())($this->createContainer([
+ 'packages' => [
+ 'ignoreRepos' => [
+ 'dotkernel.com',
+ 123,
+ true,
+ 1.5,
+ ['nested', 'array'],
+ new stdClass(),
+ null,
+ ],
+ ],
+ ]));
+
+ $this->assertSame(
+ ['dotkernel.com', '123', '1', '1.5'],
+ $this->readProperty($generator, 'ignoreRepos')
+ );
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createContainer(mixed $config): ContainerInterface
+ {
+ $container = $this->createStub(ContainerInterface::class);
+ $container
+ ->method('get')
+ ->willReturnCallback(
+ fn (string $id): mixed => $id === 'config'
+ ? $config
+ : $this->createStub(GitHubClientInterface::class)
+ );
+
+ return $container;
+ }
+
+ private function readProperty(PackageGenerator $generator, string $name): mixed
+ {
+ return (new ReflectionProperty(PackageGenerator::class, $name))->getValue($generator);
+ }
+}
diff --git a/test/Unit/App/Handler/GetPackagesViewHandlerTest.php b/test/Unit/App/Handler/GetPackagesViewHandlerTest.php
new file mode 100644
index 0000000..fb62324
--- /dev/null
+++ b/test/Unit/App/Handler/GetPackagesViewHandlerTest.php
@@ -0,0 +1,170 @@
+ 'dot-cache', 'lifecycle' => 'active', 'php' => '~8.4.0', 'archived' => false],
+ ];
+
+ $parameters = $this->render([
+ 'generated_at' => '2026-08-04T12:00:00+00:00',
+ 'org' => 'dotkernel',
+ 'packages' => $packages,
+ ]);
+
+ $this->assertSame($packages, $parameters['packages']);
+ $this->assertSame('2026-08-04T12:00:00+00:00', $parameters['generatedAt']);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleAlsoSuppliesThePostsAndCategoriesTheLayoutNeeds(): void
+ {
+ $parameters = $this->render(null);
+
+ $this->assertSame(['posts', 'categories', 'packages', 'generatedAt'], array_keys($parameters));
+ $this->assertCount(1, $parameters['posts']);
+ $this->assertCount(1, $parameters['categories']);
+ }
+
+ /**
+ * A missing, empty, or malformed data file must render an empty listing rather than fail.
+ *
+ * @param array|null $data
+ * @throws Exception
+ */
+ #[DataProvider('unusablePackagesProvider')]
+ public function testHandleFallsBackToAnEmptyPackageList(?array $data): void
+ {
+ $this->assertSame([], $this->render($data)['packages']);
+ }
+
+ /**
+ * @return array|null}>
+ */
+ public static function unusablePackagesProvider(): array
+ {
+ return [
+ 'nothing read' => [null],
+ 'empty payload' => [[]],
+ 'no packages key' => [['generated_at' => '2026-08-04T12:00:00+00:00']],
+ 'packages not an array' => [['packages' => 'nope']],
+ ];
+ }
+
+ /**
+ * `generated_at` drives the "last updated" line, which is simply omitted when it is unusable.
+ *
+ * @param array|null $data
+ * @throws Exception
+ */
+ #[DataProvider('unusableGeneratedAtProvider')]
+ public function testHandleOmitsAnUnusableGeneratedAt(?array $data): void
+ {
+ $this->assertNull($this->render($data)['generatedAt']);
+ }
+
+ /**
+ * @return array|null}>
+ */
+ public static function unusableGeneratedAtProvider(): array
+ {
+ return [
+ 'nothing read' => [null],
+ 'empty payload' => [[]],
+ 'key missing' => [['packages' => []]],
+ 'not a string' => [['packages' => [], 'generated_at' => 1234567890]],
+ 'null' => [['packages' => [], 'generated_at' => null]],
+ ];
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleReturnsAnHtmlResponse(): void
+ {
+ $template = $this->createStub(TemplateRendererInterface::class);
+ $template->method('render')->willReturn('');
+
+ $response = $this->createHandler($template, null)->handle(new ServerRequest());
+
+ $this->assertInstanceOf(HtmlResponse::class, $response);
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame('', (string) $response->getBody());
+ }
+
+ /**
+ * Renders the page and returns the parameters the handler passed to the template.
+ *
+ * @param array|null $data
+ * @return array
+ * @throws Exception
+ */
+ private function render(?array $data): array
+ {
+ $captured = [];
+ $template = $this->createMock(TemplateRendererInterface::class);
+
+ $template
+ ->expects($this->once())
+ ->method('render')
+ ->willReturnCallback(
+ function (string $name, mixed $parameters = []) use (&$captured): string {
+ $this->assertSame(GetPackagesViewHandler::TEMPLATE, $name);
+ $this->assertIsArray($parameters);
+ $captured = $parameters;
+
+ return '';
+ }
+ );
+
+ $this->createHandler($template, $data)->handle(new ServerRequest());
+
+ return $captured;
+ }
+
+ /**
+ * @param array|null $data
+ * @throws Exception
+ */
+ private function createHandler(TemplateRendererInterface $template, ?array $data): GetPackagesViewHandler
+ {
+ $packageGenerator = $this->createStub(PackageGenerator::class);
+ $packageGenerator->method('read')->willReturn($data);
+
+ $postRepository = $this->createStub(PostRepository::class);
+ $postRepository->method('getRecentPosts')->willReturn([$this->createStub(Post::class)]);
+
+ $categoryRepository = $this->createStub(CategoryRepository::class);
+ $categoryRepository->method('getCategories')->willReturn([$this->createStub(Category::class)]);
+
+ return new GetPackagesViewHandler($template, $categoryRepository, $postRepository, $packageGenerator);
+ }
+}
diff --git a/test/Unit/App/Service/Fixture/github-api-server.php b/test/Unit/App/Service/Fixture/github-api-server.php
new file mode 100644
index 0000000..bfae2b9
--- /dev/null
+++ b/test/Unit/App/Service/Fixture/github-api-server.php
@@ -0,0 +1,94 @@
+; rel="next", ; rel="last"', $host, $host));
+ echo (string) json_encode([['name' => 'one'], 'discard me', ['name' => 'two']]);
+
+ return;
+
+ case '/page-2':
+ header('Link: ');
+ echo (string) json_encode([['name' => 'three']]);
+
+ return;
+
+ case '/not-an-array':
+ echo '"a bare string"';
+
+ return;
+
+ case '/echo-request':
+ echo (string) json_encode([
+ 'accept' => $requestHeader('HTTP_ACCEPT'),
+ 'apiVersion' => $requestHeader('HTTP_X_GITHUB_API_VERSION'),
+ 'authorization' => $requestHeader('HTTP_AUTHORIZATION'),
+ 'userAgent' => $requestHeader('HTTP_USER_AGENT'),
+ ]);
+
+ return;
+}
+
+http_response_code(404);
+echo 'Unknown route';
diff --git a/test/Unit/App/Service/GitHubClientTest.php b/test/Unit/App/Service/GitHubClientTest.php
new file mode 100644
index 0000000..81e01a2
--- /dev/null
+++ b/test/Unit/App/Service/GitHubClientTest.php
@@ -0,0 +1,427 @@
+ ['file', '/dev/null', 'r'],
+ 1 => ['file', $outputFile, 'a'],
+ 2 => ['file', $outputFile, 'a'],
+ ],
+ $pipes
+ );
+
+ if (! is_resource($process)) {
+ self::markTestSkipped(sprintf('Unable to run %s -S 127.0.0.1:%d.', PHP_BINARY, $port));
+ }
+
+ self::$server = $process;
+ self::$baseUrl = sprintf('http://127.0.0.1:%d', $port);
+
+ self::waitForServer($port);
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ if (is_resource(self::$server)) {
+ proc_terminate(self::$server);
+ proc_close(self::$server);
+ }
+
+ if (self::$outputFile !== null && is_file(self::$outputFile)) {
+ unlink(self::$outputFile);
+ }
+
+ self::$server = null;
+ self::$baseUrl = '';
+ self::$outputFile = null;
+ }
+
+ public function testGetReturnsTheResponseBody(): void
+ {
+ $this->assertSame('{"lifecycle":"active"}', $this->createClient()->get($this->url('/ok')));
+ }
+
+ public function testGetReturnsNullWhenTheResourceDoesNotExist(): void
+ {
+ $this->assertNull($this->createClient()->get($this->url('/missing')));
+ }
+
+ public function testGetThrowsOnAnUnexpectedStatus(): void
+ {
+ $url = $this->url('/server-error');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage(sprintf('GitHub returned HTTP 500 for %s.', $url));
+
+ $this->createClient()->get($url);
+ }
+
+ public function testGetFollowsRedirects(): void
+ {
+ $this->assertSame('{"lifecycle":"active"}', $this->createClient()->get($this->url('/redirect')));
+ }
+
+ public function testGetThrowsWhenTheTransportFails(): void
+ {
+ $url = sprintf('http://127.0.0.1:%d/ok', self::findFreePort());
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage(sprintf('Request to %s failed:', $url));
+
+ $this->createClient()->get($url);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testGetSendsTheExpectedRequestHeaders(): void
+ {
+ $body = $this->createClient()->get($this->url('/echo-request'));
+ $this->assertIsString($body);
+
+ $this->assertSame([
+ 'accept' => GitHubClientInterface::ACCEPT_JSON,
+ 'apiVersion' => '2022-11-28',
+ 'authorization' => 'Bearer ' . self::TOKEN,
+ 'userAgent' => self::USER_AGENT,
+ ], json_decode($body, true, 512, JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testGetSendsTheRequestedAcceptHeader(): void
+ {
+ $body = $this->createClient()->get($this->url('/echo-request'), GitHubClientInterface::ACCEPT_RAW);
+ $this->assertIsString($body);
+
+ $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ $this->assertIsArray($decoded);
+ $this->assertSame(GitHubClientInterface::ACCEPT_RAW, $decoded['accept']);
+ }
+
+ /**
+ * An unauthenticated client still works, it just has a lower rate limit.
+ *
+ * @throws JsonException
+ */
+ public function testGetOmitsTheAuthorizationHeaderWithoutAToken(): void
+ {
+ $body = $this->createClient('')->get($this->url('/echo-request'));
+ $this->assertIsString($body);
+
+ $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ $this->assertIsArray($decoded);
+ $this->assertSame('', $decoded['authorization']);
+ }
+
+ /**
+ * cURL rejects an empty user agent and GitHub rejects requests without one.
+ *
+ * @throws JsonException
+ */
+ public function testAnEmptyUserAgentFallsBackToTheDefault(): void
+ {
+ $body = $this->createClient(self::TOKEN, '')->get($this->url('/echo-request'));
+ $this->assertIsString($body);
+
+ $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ $this->assertIsArray($decoded);
+ $this->assertSame('dotkernel.com', $decoded['userAgent']);
+ }
+
+ public function testGetAllPagesFollowsNextLinksAndDiscardsNonArrayMembers(): void
+ {
+ $this->assertSame(
+ [['name' => 'one'], ['name' => 'two'], ['name' => 'three']],
+ $this->createClient()->getAllPages($this->url('/page-1'))
+ );
+ }
+
+ public function testGetAllPagesReturnsASinglePageWhenThereIsNoNextLink(): void
+ {
+ $this->assertSame([['name' => 'three']], $this->createClient()->getAllPages($this->url('/page-2')));
+ }
+
+ public function testGetAllPagesThrowsWhenThePayloadIsNotAJsonArray(): void
+ {
+ $url = $this->url('/not-an-array');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage(sprintf('Expected a JSON array from %s.', $url));
+
+ $this->createClient()->getAllPages($url);
+ }
+
+ public function testGetAllPagesThrowsOnAnUnexpectedStatus(): void
+ {
+ $url = $this->url('/server-error');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage(sprintf('GitHub returned HTTP 500 for %s.', $url));
+
+ $this->createClient()->getAllPages($url);
+ }
+
+ /**
+ * A relative path is resolved against the API root rather than being used verbatim.
+ */
+ public function testRelativePathsAreResolvedAgainstTheApiRoot(): void
+ {
+ $client = $this->createClient();
+
+ $this->assertSame(
+ 'https://api.github.com/orgs/dotkernel/repos',
+ $this->invokeAbsoluteUrl($client, '/orgs/dotkernel/repos')
+ );
+ $this->assertSame(
+ 'http://example.com/passed-through',
+ $this->invokeAbsoluteUrl($client, 'http://example.com/passed-through')
+ );
+ $this->assertSame(
+ 'https://example.com/passed-through',
+ $this->invokeAbsoluteUrl($client, 'https://example.com/passed-through')
+ );
+ }
+
+ private function invokeAbsoluteUrl(GitHubClient $client, string $path): string
+ {
+ $method = new ReflectionMethod($client, 'absoluteUrl');
+ $result = $method->invoke($client, $path);
+ $this->assertIsString($result);
+
+ return $result;
+ }
+
+ public function testTheConfiguredUserAgentIsKept(): void
+ {
+ $property = new ReflectionProperty(GitHubClient::class, 'userAgent');
+
+ $this->assertSame(self::USER_AGENT, $property->getValue($this->createClient()));
+ }
+
+ private function createClient(string $token = self::TOKEN, string $userAgent = self::USER_AGENT): GitHubClient
+ {
+ return new GitHubClient($token, $userAgent, 10, 5);
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ private function url(string $path): string
+ {
+ /** @var non-empty-string $url */
+ $url = self::$baseUrl . $path;
+ $this->assertNotSame('', $url);
+
+ return $url;
+ }
+
+ /**
+ * Binding to port 0 lets the OS pick a port that is known to be free.
+ */
+ private static function findFreePort(): int
+ {
+ $socket = stream_socket_server('tcp://127.0.0.1:0', $errorNumber, $errorMessage);
+ if (! is_resource($socket)) {
+ self::fail(sprintf('Unable to reserve a local port: %s', $errorMessage));
+ }
+
+ $name = stream_socket_get_name($socket, false);
+ fclose($socket);
+
+ if ($name === false) {
+ self::fail('Unable to determine the reserved local port.');
+ }
+
+ $port = strrchr($name, ':');
+
+ return $port === false ? 0 : (int) substr($port, 1);
+ }
+
+ private static function waitForServer(int $port): void
+ {
+ for ($attempt = 0; $attempt < 100; $attempt++) {
+ if (is_resource(self::$server)) {
+ $status = proc_get_status(self::$server);
+ if ($status['running'] === false) {
+ self::markTestSkipped(sprintf(
+ 'The local GitHub API stand-in exited with code %d.%s',
+ $status['exitcode'],
+ self::serverOutput()
+ ));
+ }
+ }
+
+ if (self::probeWithARealRequest($port)) {
+ return;
+ }
+
+ usleep(50_000);
+ }
+
+ self::markTestSkipped(sprintf(
+ 'The local GitHub API stand-in never accepted a connection on port %d.%s',
+ $port,
+ self::serverOutput()
+ ));
+ }
+
+ /**
+ * Sends a complete request and reads the whole response back.
+ *
+ * The stand-in is single threaded, so it must be allowed to finish a full request cycle
+ * before the first test runs. Probing by opening a connection and dropping it without
+ * sending anything leaves the server reading EOF, and the next response comes back empty.
+ */
+ private static function probeWithARealRequest(int $port): bool
+ {
+ $connection = @fsockopen('127.0.0.1', $port, $errorNumber, $errorMessage, 0.5);
+ if (! is_resource($connection)) {
+ return false;
+ }
+
+ stream_set_timeout($connection, 1);
+ fwrite($connection, sprintf(
+ "GET /ok HTTP/1.0%sHost: 127.0.0.1:%d%sUser-Agent: readiness-probe%s%s",
+ "\r\n",
+ $port,
+ "\r\n",
+ "\r\n",
+ "\r\n"
+ ));
+
+ $response = '';
+ while (! feof($connection)) {
+ $chunk = fgets($connection);
+ if ($chunk === false) {
+ break;
+ }
+
+ $response .= $chunk;
+ }
+
+ fclose($connection);
+
+ return str_contains($response, '200') && str_contains($response, '{"lifecycle":"active"}');
+ }
+
+ /**
+ * Whatever the stand-in wrote before giving up, appended to a failure message.
+ */
+ private static function serverOutput(): string
+ {
+ if (self::$outputFile === null || ! is_file(self::$outputFile)) {
+ return '';
+ }
+
+ $output = file_get_contents(self::$outputFile);
+ if ($output === false || trim($output) === '') {
+ return ' It produced no output.';
+ }
+
+ return sprintf("%sIt reported:%s%s", PHP_EOL, PHP_EOL, trim($output));
+ }
+}
diff --git a/test/Unit/App/Service/PackageGeneratorTest.php b/test/Unit/App/Service/PackageGeneratorTest.php
new file mode 100644
index 0000000..e53d9b5
--- /dev/null
+++ b/test/Unit/App/Service/PackageGeneratorTest.php
@@ -0,0 +1,665 @@
+dataFile = sprintf(
+ '%s%slight-packages-%s%spackages.json',
+ sys_get_temp_dir(),
+ DIRECTORY_SEPARATOR,
+ bin2hex(random_bytes(8)),
+ DIRECTORY_SEPARATOR
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ([$this->dataFile, $this->dataFile . '.tmp'] as $file) {
+ if (is_file($file)) {
+ unlink($file);
+ }
+ }
+
+ $directory = dirname($this->dataFile);
+ if (is_dir($directory)) {
+ rmdir($directory);
+ }
+
+ parent::tearDown();
+ }
+
+ public function testGetDataFileReturnsTheConfiguredPath(): void
+ {
+ $generator = $this->createGenerator([], []);
+
+ $this->assertSame($this->dataFile, $generator->getDataFile());
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteCreatesTheDataFileWithTheExpectedPayload(): void
+ {
+ $generator = $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [
+ $this->metadataPath('dot-cache') => 'osslifecycle=active',
+ $this->composerPath('dot-cache') => '{"require":{"php":"~8.3.0 || ~8.4.0"}}',
+ ]
+ );
+
+ $report = $generator->write();
+
+ $this->assertSame(1, $report['written']);
+ $this->assertSame([], $report['skipped']);
+ $this->assertSame([], $report['ignoredMisses']);
+ $this->assertSame([], $report['warnings']);
+
+ $payload = $this->decodeDataFile();
+
+ $this->assertSame(self::ORG, $payload['org']);
+ $this->assertMatchesRegularExpression(
+ '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/',
+ $payload['generated_at']
+ );
+ $this->assertSame([
+ [
+ 'name' => 'dot-cache',
+ 'url' => 'https://github.com/dotkernel/dot-cache',
+ 'lifecycle' => 'active',
+ 'php' => '~8.3.0 || ~8.4.0',
+ 'archived' => false,
+ ],
+ ], $payload['packages']);
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteCreatesTheDataDirectoryWhenItDoesNotExist(): void
+ {
+ $this->assertDirectoryDoesNotExist(dirname($this->dataFile));
+
+ $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [$this->metadataPath('dot-cache') => 'osslifecycle=active']
+ )->write();
+
+ $this->assertFileExists($this->dataFile);
+ }
+
+ /**
+ * The listing is written to a sibling file and renamed, so a crash cannot leave truncated
+ * JSON behind for the request handler to read.
+ *
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteLeavesNoTemporaryFileBehind(): void
+ {
+ $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [$this->metadataPath('dot-cache') => 'osslifecycle=active']
+ )->write();
+
+ $this->assertFileDoesNotExist($this->dataFile . '.tmp');
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteSkipsIgnoredRepositoriesRegardlessOfCase(): void
+ {
+ $generator = $this->createGenerator(
+ [
+ ['name' => 'DotKernel.com', 'archived' => false],
+ ['name' => 'dot-cache', 'archived' => false],
+ ],
+ [
+ $this->metadataPath('DotKernel.com') => 'osslifecycle=active',
+ $this->metadataPath('dot-cache') => 'osslifecycle=active',
+ ],
+ ['DOTKERNEL.COM']
+ );
+
+ $report = $generator->write();
+
+ $this->assertSame(1, $report['written']);
+ $this->assertSame(['DotKernel.com'], $report['skipped']);
+ $this->assertSame([], $report['ignoredMisses']);
+ $this->assertSame(['dot-cache'], array_column($this->decodeDataFile()['packages'], 'name'));
+ }
+
+ /**
+ * A renamed or deleted repository must not silently reappear on the site.
+ *
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteReportsIgnoredEntriesThatMatchedNothing(): void
+ {
+ $generator = $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [$this->metadataPath('dot-cache') => 'osslifecycle=active'],
+ [' Renamed-Repo ', 'dot-cache', '', ' ']
+ );
+
+ $report = $generator->write();
+
+ $this->assertSame(['dot-cache'], $report['skipped']);
+ $this->assertSame(['renamed-repo'], $report['ignoredMisses']);
+ $this->assertSame(0, $report['written']);
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteIgnoresRepositoriesWithoutOssMetadata(): void
+ {
+ $generator = $this->createGenerator(
+ [
+ ['name' => 'dot-cache', 'archived' => false],
+ ['name' => 'not-a-package', 'archived' => false],
+ ],
+ [$this->metadataPath('dot-cache') => 'osslifecycle=active']
+ );
+
+ $report = $generator->write();
+
+ $this->assertSame(1, $report['written']);
+ $this->assertSame([], $report['skipped']);
+ $this->assertSame([], $report['warnings']);
+ $this->assertSame(['dot-cache'], array_column($this->decodeDataFile()['packages'], 'name'));
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteWarnsWhenOssMetadataHasNoLifecycleValue(): void
+ {
+ $generator = $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [$this->metadataPath('dot-cache') => '# nothing useful in here']
+ );
+
+ $report = $generator->write();
+
+ $this->assertSame(0, $report['written']);
+ $this->assertSame(
+ ['dot-cache: OSSMETADATA present but no osslifecycle value found, skipped'],
+ $report['warnings']
+ );
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteIgnoresRepositoriesWithoutAUsableName(): void
+ {
+ $generator = $this->createGenerator(
+ [
+ ['name' => ' '],
+ ['name' => 42],
+ ['archived' => false],
+ ['name' => 'dot-cache', 'archived' => false],
+ ],
+ [$this->metadataPath('dot-cache') => 'osslifecycle=active']
+ );
+
+ $report = $generator->write();
+
+ $this->assertSame(1, $report['written']);
+ $this->assertSame([], $report['warnings']);
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteExcludesArchivedRepositoriesWhenTheyAreNotIncluded(): void
+ {
+ $generator = $this->createGenerator(
+ [
+ ['name' => 'dot-console', 'archived' => true],
+ ['name' => 'dot-cache', 'archived' => false],
+ ],
+ [
+ $this->metadataPath('dot-console') => 'osslifecycle=archived',
+ $this->metadataPath('dot-cache') => 'osslifecycle=active',
+ ],
+ [],
+ false
+ );
+
+ $report = $generator->write();
+
+ $this->assertSame(1, $report['written']);
+ $this->assertSame(['dot-cache'], array_column($this->decodeDataFile()['packages'], 'name'));
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteFlagsArchivedRepositoriesWhenTheyAreIncluded(): void
+ {
+ $generator = $this->createGenerator(
+ [['name' => 'dot-console', 'archived' => true]],
+ [$this->metadataPath('dot-console') => 'osslifecycle=archived']
+ );
+
+ $report = $generator->write();
+
+ $this->assertSame(1, $report['written']);
+ $this->assertTrue($this->decodeDataFile()['packages'][0]['archived']);
+ }
+
+ /**
+ * Unrecognised lifecycles sort last; equal lifecycles sort by name.
+ *
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteSortsByLifecycleThenName(): void
+ {
+ $lifecycles = [
+ 'frobnicate-repo' => 'frobnicate',
+ 'zeta-repo' => 'active',
+ 'archived-repo' => 'archived',
+ 'alpha-repo' => 'active',
+ 'security-repo' => 'security-only',
+ 'maint-repo' => 'maintenance',
+ ];
+
+ $repositories = [];
+ $files = [];
+ foreach ($lifecycles as $name => $lifecycle) {
+ $repositories[] = ['name' => $name, 'archived' => false];
+ $files[$this->metadataPath($name)] = sprintf('osslifecycle=%s', $lifecycle);
+ }
+
+ $this->createGenerator($repositories, $files)->write();
+
+ $this->assertSame([
+ 'alpha-repo',
+ 'zeta-repo',
+ 'maint-repo',
+ 'security-repo',
+ 'archived-repo',
+ 'frobnicate-repo',
+ ], array_column($this->decodeDataFile()['packages'], 'name'));
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteRecordsANullPhpConstraintWhenComposerJsonIsMissing(): void
+ {
+ $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [$this->metadataPath('dot-cache') => 'osslifecycle=active']
+ )->write();
+
+ $this->assertNull($this->decodeDataFile()['packages'][0]['php']);
+ }
+
+ /**
+ * @param non-empty-string $composerJson
+ * @throws Exception
+ * @throws JsonException
+ */
+ #[DataProvider('unusablePhpConstraintProvider')]
+ public function testWriteRecordsANullPhpConstraintWhenItCannotBeRead(string $composerJson): void
+ {
+ $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [
+ $this->metadataPath('dot-cache') => 'osslifecycle=active',
+ $this->composerPath('dot-cache') => $composerJson,
+ ]
+ )->write();
+
+ $this->assertNull($this->decodeDataFile()['packages'][0]['php']);
+ }
+
+ /**
+ * @return array
+ */
+ public static function unusablePhpConstraintProvider(): array
+ {
+ return [
+ 'not json' => ['this is not json'],
+ 'json scalar' => ['"just a string"'],
+ 'no require section' => ['{"name":"dotkernel/dot-cache"}'],
+ 'require not an array' => ['{"require":"php"}'],
+ 'no php requirement' => ['{"require":{"ext-json":"*"}}'],
+ 'php not a string' => ['{"require":{"php":8.4}}'],
+ 'blank php' => ['{"require":{"php":" "}}'],
+ ];
+ }
+
+ /**
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteTrimsThePhpConstraint(): void
+ {
+ $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [
+ $this->metadataPath('dot-cache') => 'osslifecycle=active',
+ $this->composerPath('dot-cache') => '{"require":{"php":" ~8.4.0 "}}',
+ ]
+ )->write();
+
+ $this->assertSame('~8.4.0', $this->decodeDataFile()['packages'][0]['php']);
+ }
+
+ /**
+ * @param non-empty-string $metadata
+ * @throws Exception
+ * @throws JsonException
+ */
+ #[DataProvider('lifecycleProvider')]
+ public function testWriteParsesTheLifecycleValue(string $metadata, string $expected): void
+ {
+ $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [$this->metadataPath('dot-cache') => $metadata]
+ )->write();
+
+ $this->assertSame($expected, $this->decodeDataFile()['packages'][0]['lifecycle']);
+ }
+
+ /**
+ * @return array
+ */
+ public static function lifecycleProvider(): array
+ {
+ return [
+ 'bare' => ['osslifecycle=active', 'active'],
+ 'padded equals' => ['osslifecycle = maintenance', 'maintenance'],
+ 'uppercase key' => ['OSSLIFECYCLE=active', 'active'],
+ 'uppercase value' => ['osslifecycle=ACTIVE', 'active'],
+ 'trailing comment' => ['osslifecycle=active#stable', 'active'],
+ 'surrounded by lines' => ["# header\nosslifecycle=security-only\n", 'security-only'],
+ ];
+ }
+
+ /**
+ * A partial listing must never overwrite a known-good one.
+ *
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteAbortsWithoutTouchingTheDataFileWhenTooManyRequestsFail(): void
+ {
+ mkdir(dirname($this->dataFile), 0775, true);
+ file_put_contents($this->dataFile, '{"packages":["previous"]}');
+
+ $generator = $this->createGenerator(
+ [['name' => 'dot-cache', 'archived' => false]],
+ [$this->metadataPath('dot-cache') => new RuntimeException('HTTP 503')]
+ );
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Aborting without writing: 1 of 1 requests failed');
+
+ try {
+ $generator->write();
+ } finally {
+ $this->assertSame('{"packages":["previous"]}', file_get_contents($this->dataFile));
+ $this->assertFileDoesNotExist($this->dataFile . '.tmp');
+ }
+ }
+
+ /**
+ * One failure in nine attempts stays under the 20% threshold, so the run still writes.
+ *
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteWarnsButStillWritesWhenAMetadataRequestFailsBelowTheThreshold(): void
+ {
+ $repositories = [['name' => 'broken-repo', 'archived' => false]];
+ $files = [$this->metadataPath('broken-repo') => new RuntimeException('HTTP 503')];
+
+ foreach (['repo-a', 'repo-b', 'repo-c', 'repo-d'] as $name) {
+ $repositories[] = ['name' => $name, 'archived' => false];
+ $files[$this->metadataPath($name)] = 'osslifecycle=active';
+ $files[$this->composerPath($name)] = '{"require":{"php":"~8.4.0"}}';
+ }
+
+ $report = $this->createGenerator($repositories, $files)->write();
+
+ $this->assertSame(4, $report['written']);
+ $this->assertSame(
+ ['broken-repo: could not read OSSMETADATA (HTTP 503)'],
+ $report['warnings']
+ );
+ $this->assertSame(
+ ['repo-a', 'repo-b', 'repo-c', 'repo-d'],
+ array_column($this->decodeDataFile()['packages'], 'name')
+ );
+ }
+
+ /**
+ * A failed composer.json read costs the constraint, not the package.
+ *
+ * @throws Exception
+ * @throws JsonException
+ */
+ public function testWriteKeepsThePackageWhenTheComposerRequestFails(): void
+ {
+ $repositories = [['name' => 'broken-repo', 'archived' => false]];
+ $files = [
+ $this->metadataPath('broken-repo') => 'osslifecycle=active',
+ $this->composerPath('broken-repo') => new RuntimeException('HTTP 500'),
+ ];
+
+ foreach (['repo-a', 'repo-b', 'repo-c', 'repo-d'] as $name) {
+ $repositories[] = ['name' => $name, 'archived' => false];
+ $files[$this->metadataPath($name)] = 'osslifecycle=active';
+ $files[$this->composerPath($name)] = '{"require":{"php":"~8.4.0"}}';
+ }
+
+ $report = $this->createGenerator($repositories, $files)->write();
+
+ $this->assertSame(5, $report['written']);
+ $this->assertSame(
+ ['broken-repo: could not read composer.json (HTTP 500)'],
+ $report['warnings']
+ );
+
+ $packages = array_column($this->decodeDataFile()['packages'], 'php', 'name');
+ $this->assertNull($packages['broken-repo']);
+ $this->assertSame('~8.4.0', $packages['repo-a']);
+ }
+
+ public function testReadReturnsNullWhenTheDataFileIsMissing(): void
+ {
+ $this->assertNull($this->createGenerator([], [])->read());
+ }
+
+ /**
+ * @param non-empty-string $contents
+ */
+ #[DataProvider('unusableDataFileProvider')]
+ public function testReadReturnsNullWhenTheDataFileCannotBeUsed(string $contents): void
+ {
+ $this->writeDataFile($contents);
+
+ $this->assertNull($this->createGenerator([], [])->read());
+ }
+
+ /**
+ * @return array
+ */
+ public static function unusableDataFileProvider(): array
+ {
+ return [
+ 'empty' => [' '],
+ 'invalid json' => ['{not json'],
+ 'json scalar' => ['"a string"'],
+ 'no packages key' => ['{"generated_at":"now"}'],
+ 'packages not array' => ['{"packages":"nope"}'],
+ ];
+ }
+
+ public function testReadReturnsTheDecodedPayload(): void
+ {
+ $this->writeDataFile('{"generated_at":"2026-08-04T12:00:00+00:00","org":"dotkernel","packages":[]}');
+
+ $this->assertSame([
+ 'generated_at' => '2026-08-04T12:00:00+00:00',
+ 'org' => 'dotkernel',
+ 'packages' => [],
+ ], $this->createGenerator([], [])->read());
+ }
+
+ /**
+ * @param list> $repositories
+ * @param array $files Response per requested path; an absent
+ * key stands in for a 404.
+ * @param list $ignoreRepos
+ * @throws Exception
+ */
+ private function createGenerator(
+ array $repositories,
+ array $files,
+ array $ignoreRepos = [],
+ bool $includeArchived = true,
+ ): PackageGenerator {
+ $client = $this->createMock(GitHubClientInterface::class);
+
+ $client
+ ->method('getAllPages')
+ ->with(sprintf('/orgs/%s/repos?per_page=100&type=public', self::ORG))
+ ->willReturn($repositories);
+
+ $client
+ ->method('get')
+ ->willReturnCallback(
+ function (string $path, string $accept = GitHubClientInterface::ACCEPT_JSON) use ($files): ?string {
+ $this->assertSame(GitHubClientInterface::ACCEPT_RAW, $accept);
+
+ if (! array_key_exists($path, $files)) {
+ return null;
+ }
+
+ $response = $files[$path];
+ if ($response instanceof RuntimeException) {
+ throw $response;
+ }
+
+ return $response;
+ }
+ );
+
+ return new PackageGenerator(
+ $client,
+ $this->dataFile,
+ self::ORG,
+ $ignoreRepos,
+ $includeArchived
+ );
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ private function metadataPath(string $repository): string
+ {
+ return sprintf('/repos/%s/%s/contents/OSSMETADATA', self::ORG, $repository);
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ private function composerPath(string $repository): string
+ {
+ return sprintf('/repos/%s/%s/contents/composer.json', self::ORG, $repository);
+ }
+
+ private function writeDataFile(string $contents): void
+ {
+ $directory = dirname($this->dataFile);
+ if (! is_dir($directory)) {
+ mkdir($directory, 0775, true);
+ }
+
+ file_put_contents($this->dataFile, $contents);
+ }
+
+ /**
+ * @return array{generated_at: string, org: string, packages: list>}
+ * @throws JsonException
+ */
+ private function decodeDataFile(): array
+ {
+ $this->assertFileExists($this->dataFile);
+
+ $contents = file_get_contents($this->dataFile);
+ $this->assertIsString($contents);
+
+ /** @var array{generated_at: string, org: string, packages: list>} $decoded */
+ $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
+ $this->assertIsArray($decoded);
+ $this->assertArrayHasKey('generated_at', $decoded);
+ $this->assertArrayHasKey('org', $decoded);
+ $this->assertArrayHasKey('packages', $decoded);
+ $this->assertIsString($decoded['generated_at']);
+ $this->assertIsString($decoded['org']);
+ $this->assertIsArray($decoded['packages']);
+
+ return $decoded;
+ }
+}