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

light

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

admin

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

queue

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

api

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-sso-entra

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

frontend

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-cli

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-event

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-mail

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-twigrenderer

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-totp

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-authorization

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-maker

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-geoip

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-annotated-services

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-navigation

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-flashmessenger

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-session

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-log

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-errorhandler

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-form

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-rbac-guard

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-mail-outlook

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-rbac-route-guard

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-controller

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-rbac

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-authentication

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-user-agent-sniffer

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-auth-social

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-helpers

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-router

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-response-header

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-dependency-injection

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-cache

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-data-fixtures

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Active

dot-debugbar

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-doctrine-metadata

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-mapper

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-paginator

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-validator

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-inputfilter

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Maintenance

dot-authentication-service

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-hydrator

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-authentication-web

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-controller-plugin-mail

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-controller-plugin-session

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-user

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-controller-plugin-flashmessenger

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-filter

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-controller-plugin-forms

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-controller-plugin-authorization

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Security-only

dot-controller-plugin-authentication

-
-
- PHP version -
-
- GitHub -
-
- -
-
-
Archived

dot-console

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

{{ package.name }}

+
+
+ PHP version for dotkernel/{{ package.name }} + {% if package.archived %} +

Archived on GitHub

+ {% endif %} +
+
+ GitHub +
+
+ {% 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; + } +}