diff --git a/README.md b/README.md index b640e0b..dd048d6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,28 @@ A Symfony bundle that fetches commit history from GitLab or GitHub repositories - **Standalone Page**: Ready-to-use page with embedded CSS - **Embeddable Fragment**: Include the timeline in your own layouts +## Architecture + +This project is split into two packages: + +- **[spiriitlabs/commit-history](https://github.com/SpiriitLabs/commit-history)** - A standalone, framework-agnostic PHP library containing all the core logic (providers, DTOs, services, diff parsers) +- **spiriitlabs/commit-history-bundle** (this package) - A Symfony bundle providing controllers, commands, Twig templates, and adapters to integrate the library with Symfony + +This separation allows you to use the core library in any PHP project (Laravel, plain PHP, etc.) while Symfony users get a ready-to-use bundle with full integration. + +### Bundle Components + +The bundle provides Symfony-specific integrations: + +| Component | Description | +|-----------|-------------| +| `SymfonyCacheAdapter` | Bridges Symfony's cache to the library's `CacheInterface` | +| `SymfonyHttpClientAdapter` | Bridges Symfony's HTTP client to the library's `HttpClientInterface` | +| `TimelineController` | Renders the timeline page | +| `DependenciesChangesController` | JSON API for dependency details | +| `RefreshCacheCommand` | CLI command to refresh cache | +| `ClearCacheCommand` | CLI command to clear cache | + ## Requirements - PHP 8.2 or higher @@ -143,7 +165,7 @@ You can embed the timeline in your own templates by injecting the `FeedFetcherIn namespace App\Controller; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -246,15 +268,15 @@ spiriit_commit_history: The bundle uses a tagged service pattern for diff parsers. You can add support for additional dependency file formats by creating your own parser. -1. Create a parser class implementing `DiffParserInterface`: +1. Create a parser class implementing `DiffParserInterface` from the library: ```php =8.2", + "spiriitlabs/commit-history": "^1.0", "symfony/cache": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", "symfony/framework-bundle": "^6.4|^7.0", diff --git a/src/Adapter/SymfonyHttpClientAdapter.php b/src/Adapter/SymfonyHttpClientAdapter.php new file mode 100644 index 0000000..210f85e --- /dev/null +++ b/src/Adapter/SymfonyHttpClientAdapter.php @@ -0,0 +1,41 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Adapter; + +use Spiriit\CommitHistory\Contract\HttpClientInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface as SymfonyHttpClientInterface; + +final class SymfonyHttpClientAdapter implements HttpClientInterface +{ + public function __construct( + private readonly SymfonyHttpClientInterface $httpClient, + ) { + } + + /** + * @param array $headers + * + * @return array{status: int, headers: array>, body: string} + */ + public function request(string $method, string $url, array $headers = []): array + { + $response = $this->httpClient->request($method, $url, [ + 'headers' => $headers, + ]); + + return [ + 'status' => $response->getStatusCode(), + 'headers' => $response->getHeaders(), + 'body' => $response->getContent(), + ]; + } +} diff --git a/src/Command/ClearCacheCommand.php b/src/Command/ClearCacheCommand.php index 5822903..2ce1946 100644 --- a/src/Command/ClearCacheCommand.php +++ b/src/Command/ClearCacheCommand.php @@ -12,8 +12,8 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Command; use Spiriit\Bundle\CommitHistoryBundle\Controller\DependenciesChangesController; -use Spiriit\Bundle\CommitHistoryBundle\Service\DependencyDetectionService; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingDependencyDetectionService; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingFeedFetcherInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -31,7 +31,7 @@ class ClearCacheCommand extends Command { public function __construct( private readonly CacheInterface $cache, - private readonly FeedFetcherInterface $feedFetcher, + private readonly CachingFeedFetcherInterface $feedFetcher, ) { parent::__construct(); } @@ -101,7 +101,7 @@ private function doClearYear(int $year): int private function clearDependencyCaches(string $commitId): void { // Clear dependency detection cache (badge) - $hasDepsKey = DependencyDetectionService::getCacheKeyPrefix().$commitId; + $hasDepsKey = CachingDependencyDetectionService::getCacheKeyPrefix().$commitId; $this->cache->delete($hasDepsKey); // Clear dependency changes cache (list) diff --git a/src/Command/RefreshCacheCommand.php b/src/Command/RefreshCacheCommand.php index b9d851b..6a94c15 100644 --- a/src/Command/RefreshCacheCommand.php +++ b/src/Command/RefreshCacheCommand.php @@ -11,8 +11,8 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Command; -use Spiriit\Bundle\CommitHistoryBundle\Service\DependencyDetectionService; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingDependencyDetectionService; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingFeedFetcherInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -29,7 +29,7 @@ class RefreshCacheCommand extends Command { public function __construct( - private readonly FeedFetcherInterface $feedFetcher, + private readonly CachingFeedFetcherInterface $feedFetcher, private readonly CacheInterface $cache, ) { parent::__construct(); @@ -72,7 +72,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * @return \Spiriit\Bundle\CommitHistoryBundle\DTO\Commit[] + * @return \Spiriit\CommitHistory\DTO\Commit[] */ private function refreshYear(int $year): array { @@ -81,7 +81,7 @@ private function refreshYear(int $year): array // Clear dependency detection cache for existing commits foreach ($existingCommits as $commit) { - $hasDepsKey = DependencyDetectionService::getCacheKeyPrefix().$commit->id; + $hasDepsKey = CachingDependencyDetectionService::getCacheKeyPrefix().$commit->id; $this->cache->delete($hasDepsKey); } diff --git a/src/Controller/DependenciesChangesController.php b/src/Controller/DependenciesChangesController.php index a49005f..c7a463d 100644 --- a/src/Controller/DependenciesChangesController.php +++ b/src/Controller/DependenciesChangesController.php @@ -11,9 +11,9 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Controller; -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\DiffParserRegistry; +use Spiriit\CommitHistory\DiffParser\DiffParserRegistry; +use Spiriit\CommitHistory\DTO\DependencyChange; +use Spiriit\CommitHistory\Provider\ProviderInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\Cache\CacheInterface; diff --git a/src/Controller/TimelineController.php b/src/Controller/TimelineController.php index 983277e..e00c540 100644 --- a/src/Controller/TimelineController.php +++ b/src/Controller/TimelineController.php @@ -11,7 +11,7 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Controller; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Twig\Environment; diff --git a/src/DTO/Commit.php b/src/DTO/Commit.php deleted file mode 100644 index 9bb1403..0000000 --- a/src/DTO/Commit.php +++ /dev/null @@ -1,39 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\DTO; - -readonly class Commit -{ - public function __construct( - public string $id, - public string $title, - public \DateTimeImmutable $date, - public string $author, - public string $url, - public ?string $authorEmail = null, - public bool $hasDependenciesChanges = false, - ) { - } - - public function withHasDependenciesChanges(bool $hasDependenciesChanges): self - { - return new self( - $this->id, - $this->title, - $this->date, - $this->author, - $this->url, - $this->authorEmail, - $hasDependenciesChanges, - ); - } -} diff --git a/src/DTO/DependencyChange.php b/src/DTO/DependencyChange.php deleted file mode 100644 index d587e16..0000000 --- a/src/DTO/DependencyChange.php +++ /dev/null @@ -1,28 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\DTO; - -readonly class DependencyChange -{ - public const TYPE_ADDED = 'added'; - public const TYPE_UPDATED = 'updated'; - public const TYPE_REMOVED = 'removed'; - - public function __construct( - public string $name, - public string $type, - public ?string $oldVersion = null, - public ?string $newVersion = null, - public ?string $sourceFile = null, - ) { - } -} diff --git a/src/DependencyInjection/SpiriitCommitHistoryExtension.php b/src/DependencyInjection/SpiriitCommitHistoryExtension.php index 52d719e..8951015 100644 --- a/src/DependencyInjection/SpiriitCommitHistoryExtension.php +++ b/src/DependencyInjection/SpiriitCommitHistoryExtension.php @@ -11,7 +11,7 @@ namespace Spiriit\Bundle\CommitHistoryBundle\DependencyInjection; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; +use Spiriit\CommitHistory\Provider\ProviderInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; @@ -47,6 +47,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('spiriit_commit_history.github.token', $github['token'] ?? null); $container->setParameter('spiriit_commit_history.github.ref', $github['ref'] ?? null); + // Compute provider hash for unique cache keys + $providerHash = $this->computeProviderHash($config); + $container->setParameter('spiriit_commit_history.provider_hash', $providerHash); + // Load services $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services.php'); @@ -66,4 +70,19 @@ private function configureProvider(ContainerBuilder $container, string $provider $container->setAlias('spiriit_commit_history.provider', $providerServiceId); $container->setAlias(ProviderInterface::class, $providerServiceId); } + + /** + * Compute a unique hash for the provider configuration. + * Used to create unique cache keys per provider instance. + * + * @param array $config + */ + private function computeProviderHash(array $config): string + { + return match ($config['provider']) { + 'gitlab' => md5($config['gitlab']['base_url'].'_'.$config['gitlab']['project_id']), + 'github' => md5($config['github']['base_url'].'_'.$config['github']['owner'].'_'.$config['github']['repo']), + default => md5($config['provider']), + }; + } } diff --git a/src/Provider/CommitParserInterface.php b/src/Provider/CommitParserInterface.php deleted file mode 100644 index 75954ef..0000000 --- a/src/Provider/CommitParserInterface.php +++ /dev/null @@ -1,22 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Provider; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; - -interface CommitParserInterface -{ - /** - * @param array $data - */ - public function parse(array $data): Commit; -} diff --git a/src/Provider/Github/CommitParser.php b/src/Provider/Github/CommitParser.php deleted file mode 100644 index aa07dbb..0000000 --- a/src/Provider/Github/CommitParser.php +++ /dev/null @@ -1,43 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Provider\Github; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\CommitParserInterface; - -class CommitParser implements CommitParserInterface -{ - /** - * @param array $data - */ - public function parse(array $data): Commit - { - $commitData = $data['commit'] ?? []; - $authorData = $commitData['author'] ?? []; - - return new Commit( - id: substr((string) $data['sha'], 0, 8), - title: $this->extractTitle((string) ($commitData['message'] ?? '')), - date: new \DateTimeImmutable((string) ($authorData['date'] ?? 'now')), - author: (string) ($authorData['name'] ?? ''), - url: (string) ($data['html_url'] ?? ''), - authorEmail: $authorData['email'] ?? null, - ); - } - - private function extractTitle(string $message): string - { - $lines = explode("\n", $message); - - return trim($lines[0]); - } -} diff --git a/src/Provider/Github/Provider.php b/src/Provider/Github/Provider.php deleted file mode 100644 index 57813df..0000000 --- a/src/Provider/Github/Provider.php +++ /dev/null @@ -1,147 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Provider\Github; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\CommitParserInterface; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; - -class Provider implements ProviderInterface -{ - public function __construct( - private readonly HttpClientInterface $httpClient, - private readonly CommitParserInterface $parser, - private readonly string $baseUrl, - private readonly string $owner, - private readonly string $repo, - private readonly ?string $token = null, - private readonly ?string $ref = null, - ) { - } - - /** - * @return Commit[] - */ - public function getCommits(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array - { - $commits = []; - $url = rtrim($this->baseUrl, '/').'/repos/'.$this->owner.'/'.$this->repo.'/commits'; - $params = ['per_page' => 100]; - - if (null !== $this->ref) { - $params['sha'] = $this->ref; - } - - if (null !== $since) { - $params['since'] = $since->format('c'); - } - - if (null !== $until) { - $params['until'] = $until->format('c'); - } - - do { - $options = [ - 'headers' => [ - 'Accept' => 'application/vnd.github+json', - ], - 'query' => $params, - ]; - - if (null !== $this->token) { - $options['headers']['Authorization'] = 'Bearer '.$this->token; - } - - $response = $this->httpClient->request('GET', $url, $options); - $data = $response->toArray(); - - foreach ($data as $item) { - $commits[] = $this->parser->parse($item); - } - - // Parse Link header for next page - $headers = $response->getHeaders(); - $linkHeader = $headers['link'][0] ?? ''; - $url = $this->extractNextUrl($linkHeader); - $params = []; // URL already contains query params - } while (null !== $url && !empty($data)); - - return $commits; - } - - private function extractNextUrl(string $linkHeader): ?string - { - if (preg_match('/<([^>]+)>;\s*rel="next"/', $linkHeader, $matches)) { - return $matches[1]; - } - - return null; - } - - /** - * @return string[] - */ - public function getCommitFileNames(string $commitId): array - { - $data = $this->fetchCommitDetails($commitId); - - $files = []; - foreach ($data['files'] ?? [] as $file) { - if (!empty($file['filename'])) { - $files[] = $file['filename']; - } - } - - return $files; - } - - /** - * @return array - */ - public function getCommitDiff(string $commitId): array - { - $data = $this->fetchCommitDetails($commitId); - - $result = []; - foreach ($data['files'] ?? [] as $file) { - $filename = $file['filename'] ?? ''; - if (!empty($filename) && isset($file['patch'])) { - $result[$filename] = $file['patch']; - } - } - - return $result; - } - - /** - * @return array - */ - private function fetchCommitDetails(string $commitId): array - { - $url = rtrim($this->baseUrl, '/').'/repos/'.$this->owner.'/'.$this->repo.'/commits/'.$commitId; - - $options = [ - 'headers' => [ - 'Accept' => 'application/vnd.github+json', - ], - ]; - - if (null !== $this->token) { - $options['headers']['Authorization'] = 'Bearer '.$this->token; - } - - $response = $this->httpClient->request('GET', $url, $options); - - return $response->toArray(); - } -} diff --git a/src/Provider/Gitlab/CommitParser.php b/src/Provider/Gitlab/CommitParser.php deleted file mode 100644 index 8d2a14c..0000000 --- a/src/Provider/Gitlab/CommitParser.php +++ /dev/null @@ -1,33 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Provider\Gitlab; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\CommitParserInterface; - -class CommitParser implements CommitParserInterface -{ - /** - * @param array $data - */ - public function parse(array $data): Commit - { - return new Commit( - id: substr((string) $data['id'], 0, 8), - title: (string) $data['title'], - date: new \DateTimeImmutable((string) $data['created_at']), - author: (string) $data['author_name'], - url: (string) $data['web_url'], - authorEmail: $data['author_email'] ?? null, - ); - } -} diff --git a/src/Provider/Gitlab/Provider.php b/src/Provider/Gitlab/Provider.php deleted file mode 100644 index 06972ab..0000000 --- a/src/Provider/Gitlab/Provider.php +++ /dev/null @@ -1,136 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Provider\Gitlab; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\CommitParserInterface; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; - -class Provider implements ProviderInterface -{ - public function __construct( - private readonly HttpClientInterface $httpClient, - private readonly CommitParserInterface $parser, - private readonly string $baseUrl, - private readonly string $projectId, - private readonly ?string $token = null, - private readonly ?string $ref = null, - ) { - } - - /** - * @return Commit[] - */ - public function getCommits(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array - { - $commits = []; - $page = 1; - $perPage = 100; - - do { - $url = rtrim($this->baseUrl, '/').'/api/v4/projects/'.urlencode($this->projectId).'/repository/commits'; - - $options = [ - 'query' => [ - 'page' => $page, - 'per_page' => $perPage, - ], - ]; - - if (null !== $this->ref) { - $options['query']['ref_name'] = $this->ref; - } - - if (null !== $since) { - $options['query']['since'] = $since->format('c'); - } - - if (null !== $until) { - $options['query']['until'] = $until->format('c'); - } - - if (null !== $this->token) { - $options['headers'] = ['PRIVATE-TOKEN' => $this->token]; - } - - $response = $this->httpClient->request('GET', $url, $options); - $data = $response->toArray(); - - if (empty($data)) { - break; - } - - foreach ($data as $item) { - $commits[] = $this->parser->parse($item); - } - - ++$page; - } while (\count($data) === $perPage); - - return $commits; - } - - /** - * @return string[] - */ - public function getCommitFileNames(string $commitId): array - { - $diffs = $this->fetchCommitDiff($commitId); - - $files = []; - foreach ($diffs as $diff) { - if (!empty($diff['new_path'])) { - $files[] = $diff['new_path']; - } elseif (!empty($diff['old_path'])) { - $files[] = $diff['old_path']; - } - } - - return array_unique($files); - } - - /** - * @return array - */ - public function getCommitDiff(string $commitId): array - { - $diffs = $this->fetchCommitDiff($commitId); - - $result = []; - foreach ($diffs as $diff) { - $filename = $diff['new_path'] ?? $diff['old_path'] ?? ''; - if (!empty($filename) && isset($diff['diff'])) { - $result[$filename] = $diff['diff']; - } - } - - return $result; - } - - /** - * @return array> - */ - private function fetchCommitDiff(string $commitId): array - { - $url = rtrim($this->baseUrl, '/').'/api/v4/projects/'.urlencode($this->projectId).'/repository/commits/'.$commitId.'/diff'; - - $options = []; - if (null !== $this->token) { - $options['headers'] = ['PRIVATE-TOKEN' => $this->token]; - } - - $response = $this->httpClient->request('GET', $url, $options); - - return $response->toArray(); - } -} diff --git a/src/Provider/ProviderInterface.php b/src/Provider/ProviderInterface.php deleted file mode 100644 index bede9f2..0000000 --- a/src/Provider/ProviderInterface.php +++ /dev/null @@ -1,36 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Provider; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; - -interface ProviderInterface -{ - /** - * @return Commit[] - */ - public function getCommits(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array; - - /** - * Get list of files changed in a commit. - * - * @return string[] List of file paths - */ - public function getCommitFileNames(string $commitId): array; - - /** - * Get the diff/patch content for a specific commit. - * - * @return array Map of filename => diff content - */ - public function getCommitDiff(string $commitId): array; -} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index dca5d68..0da62de 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -11,30 +11,42 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Spiriit\Bundle\CommitHistoryBundle\Adapter\SymfonyHttpClientAdapter; use Spiriit\Bundle\CommitHistoryBundle\Command\ClearCacheCommand; use Spiriit\Bundle\CommitHistoryBundle\Command\RefreshCacheCommand; use Spiriit\Bundle\CommitHistoryBundle\Controller\DependenciesChangesController; use Spiriit\Bundle\CommitHistoryBundle\Controller\TimelineController; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Github\CommitParser as GithubCommitParser; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Github\Provider as GithubProvider; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Gitlab\CommitParser as GitlabCommitParser; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Gitlab\Provider as GitlabProvider; -use Spiriit\Bundle\CommitHistoryBundle\Service\DependencyDetectionService; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\ComposerDiffParser; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\DiffParserRegistry; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\PackageDiffParser; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcher; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingDependencyDetectionService; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingFeedFetcher; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingFeedFetcherInterface; +use Spiriit\CommitHistory\Contract\HttpClientInterface; +use Spiriit\CommitHistory\DiffParser\ComposerDiffParser; +use Spiriit\CommitHistory\DiffParser\DiffParserRegistry; +use Spiriit\CommitHistory\DiffParser\PackageDiffParser; +use Spiriit\CommitHistory\Provider\Github\CommitParser as GithubCommitParser; +use Spiriit\CommitHistory\Provider\Github\GithubProvider; +use Spiriit\CommitHistory\Provider\Gitlab\CommitParser as GitlabCommitParser; +use Spiriit\CommitHistory\Provider\Gitlab\GitlabProvider; +use Spiriit\CommitHistory\Service\FeedFetcher; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; return static function (ContainerConfigurator $container): void { $services = $container->services(); + // HTTP Client Adapter (bridges Symfony to library contract) + $services->set('spiriit_commit_history.http_client_adapter', SymfonyHttpClientAdapter::class) + ->args([ + service('http_client'), + ]); + + $services->alias(HttpClientInterface::class, 'spiriit_commit_history.http_client_adapter'); + // GitLab services $services->set('spiriit_commit_history.gitlab.parser', GitlabCommitParser::class); $services->set('spiriit_commit_history.gitlab.provider', GitlabProvider::class) ->args([ - service('http_client'), + service('spiriit_commit_history.http_client_adapter'), service('spiriit_commit_history.gitlab.parser'), param('spiriit_commit_history.gitlab.base_url'), param('spiriit_commit_history.gitlab.project_id'), @@ -47,7 +59,7 @@ $services->set('spiriit_commit_history.github.provider', GithubProvider::class) ->args([ - service('http_client'), + service('spiriit_commit_history.http_client_adapter'), service('spiriit_commit_history.github.parser'), param('spiriit_commit_history.github.base_url'), param('spiriit_commit_history.github.owner'), @@ -56,35 +68,6 @@ param('spiriit_commit_history.github.ref'), ]); - // FeedFetcher (caching wrapper) - $services->set('spiriit_commit_history.feed_fetcher', FeedFetcher::class) - ->args([ - service('spiriit_commit_history.provider'), - service('cache.app'), - param('spiriit_commit_history.cache_ttl'), - param('spiriit_commit_history.available_years_count'), - service('spiriit_commit_history.dependency_detection'), - ]); - - $services->alias(FeedFetcherInterface::class, 'spiriit_commit_history.feed_fetcher'); - - // Controller - $services->set('spiriit_commit_history.controller.timeline', TimelineController::class) - ->args([ - service('spiriit_commit_history.feed_fetcher'), - service('twig'), - param('spiriit_commit_history.feed_name'), - ]) - ->tag('controller.service_arguments'); - - // Commands - $services->set('spiriit_commit_history.command.refresh_cache', RefreshCacheCommand::class) - ->args([ - service(FeedFetcherInterface::class), - service('cache.app'), - ]) - ->tag('console.command'); - // Diff Parsers (tagged for auto-discovery) $services->set('spiriit_commit_history.diff_parser.composer', ComposerDiffParser::class) ->tag('spiriit_commit_history.diff_parser'); @@ -98,16 +81,45 @@ tagged_iterator('spiriit_commit_history.diff_parser'), ]); - // Dependency Detection Service - $services->set('spiriit_commit_history.dependency_detection', DependencyDetectionService::class) + // Caching Dependency Detection Service (extends library's service with caching) + $services->set('spiriit_commit_history.dependency_detection', CachingDependencyDetectionService::class) ->args([ service('spiriit_commit_history.provider'), - service('cache.app'), param('spiriit_commit_history.dependency_files'), param('spiriit_commit_history.track_dependency_changes'), + service('cache.app'), + ]); + + // Library's FeedFetcher (inner, no caching of commits list) + $services->set('spiriit_commit_history.feed_fetcher.inner', FeedFetcher::class) + ->args([ + service('spiriit_commit_history.provider'), + param('spiriit_commit_history.available_years_count'), + service('spiriit_commit_history.dependency_detection'), + ]); + + // Caching FeedFetcher (decorator that adds commits list caching) + $services->set('spiriit_commit_history.feed_fetcher', CachingFeedFetcher::class) + ->args([ + service('spiriit_commit_history.feed_fetcher.inner'), + service('cache.app'), + param('spiriit_commit_history.cache_ttl'), + param('spiriit_commit_history.provider_hash'), ]); - // Dependencies Changes Controller + $services->alias(FeedFetcherInterface::class, 'spiriit_commit_history.feed_fetcher'); + $services->alias(CachingFeedFetcherInterface::class, 'spiriit_commit_history.feed_fetcher'); + + // Controller + $services->set('spiriit_commit_history.controller.timeline', TimelineController::class) + ->args([ + service('spiriit_commit_history.feed_fetcher'), + service('twig'), + param('spiriit_commit_history.feed_name'), + ]) + ->tag('controller.service_arguments'); + + // Dependencies Changes Controller (has its own caching for dependency changes) $services->set('spiriit_commit_history.controller.dependencies_changes', DependenciesChangesController::class) ->args([ service('spiriit_commit_history.provider'), @@ -118,11 +130,18 @@ ]) ->tag('controller.service_arguments'); - // Clear Cache Command + // Commands + $services->set('spiriit_commit_history.command.refresh_cache', RefreshCacheCommand::class) + ->args([ + service(CachingFeedFetcherInterface::class), + service('cache.app'), + ]) + ->tag('console.command'); + $services->set('spiriit_commit_history.command.clear_cache', ClearCacheCommand::class) ->args([ service('cache.app'), - service(FeedFetcherInterface::class), + service(CachingFeedFetcherInterface::class), ]) ->tag('console.command'); }; diff --git a/src/Service/CachingDependencyDetectionService.php b/src/Service/CachingDependencyDetectionService.php new file mode 100644 index 0000000..9858cc6 --- /dev/null +++ b/src/Service/CachingDependencyDetectionService.php @@ -0,0 +1,66 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Service; + +use Spiriit\CommitHistory\Provider\ProviderInterface; +use Spiriit\CommitHistory\Service\DependencyDetectionService; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; + +/** + * Decorator that adds caching to the library's DependencyDetectionService. + * Caches dependency detection results per-commit with infinite TTL (commit IDs are immutable). + */ +final class CachingDependencyDetectionService extends DependencyDetectionService +{ + private const CACHE_KEY_PREFIX = 'spiriit_commit_history_has_deps_'; + + private CacheInterface $cache; + + /** + * @param string[] $dependencyFiles + */ + public function __construct( + ProviderInterface $provider, + array $dependencyFiles, + bool $trackDependencyChanges, + CacheInterface $cache, + ) { + parent::__construct($provider, $dependencyFiles, $trackDependencyChanges); + $this->cache = $cache; + } + + /** + * Check if a commit has dependency changes. + * Result is cached per commit ID (forever, since commit content is immutable). + */ + public function hasDependencyChanges(string $commitId): bool + { + $cacheKey = self::CACHE_KEY_PREFIX.$commitId; + + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($commitId): bool { + // Cache forever (null TTL) since commit IDs are immutable + $item->expiresAfter(null); + + return parent::hasDependencyChanges($commitId); + }); + } + + /** + * Get the cache key prefix for dependency detection. + * Useful for cache clearing commands. + */ + public static function getCacheKeyPrefix(): string + { + return self::CACHE_KEY_PREFIX; + } +} diff --git a/src/Service/CachingFeedFetcher.php b/src/Service/CachingFeedFetcher.php new file mode 100644 index 0000000..cfc1361 --- /dev/null +++ b/src/Service/CachingFeedFetcher.php @@ -0,0 +1,80 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Service; + +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; + +/** + * Decorator that adds caching to the library's FeedFetcher. + * Caches commits list per-year with configurable TTL. + */ +final class CachingFeedFetcher implements CachingFeedFetcherInterface +{ + private const CACHE_KEY_PREFIX = 'spiriit_commit_history_feed_'; + + public function __construct( + private readonly FeedFetcherInterface $inner, + private readonly CacheInterface $cache, + private readonly int $cacheTtl, + private readonly string $providerHash, + ) { + } + + /** + * @return Commit[] + */ + public function fetch(?int $year = null): array + { + $year = $year ?? (int) date('Y'); + $cacheKey = $this->getCacheKey($year); + + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($year): array { + $commits = $this->inner->fetch($year); + + // Don't cache empty results for long + if (empty($commits)) { + $item->expiresAfter(60); // Cache empty results for 1 minute + } else { + $item->expiresAfter($this->cacheTtl); + } + + return $commits; + }); + } + + /** + * @return Commit[] + */ + public function refresh(?int $year = null): array + { + $year = $year ?? (int) date('Y'); + $this->cache->delete($this->getCacheKey($year)); + + return $this->fetch($year); + } + + /** + * @return int[] + */ + public function getAvailableYears(): array + { + return $this->inner->getAvailableYears(); + } + + public function getCacheKey(int $year): string + { + return self::CACHE_KEY_PREFIX.$this->providerHash.'_'.$year; + } +} diff --git a/src/Service/FeedFetcherInterface.php b/src/Service/CachingFeedFetcherInterface.php similarity index 63% rename from src/Service/FeedFetcherInterface.php rename to src/Service/CachingFeedFetcherInterface.php index a00cfaa..79a68c0 100644 --- a/src/Service/FeedFetcherInterface.php +++ b/src/Service/CachingFeedFetcherInterface.php @@ -11,29 +11,22 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Service; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; -interface FeedFetcherInterface +/** + * Extended interface for FeedFetcher with caching capabilities. + */ +interface CachingFeedFetcherInterface extends FeedFetcherInterface { /** - * @return Commit[] - */ - public function fetch(?int $year = null): array; - - /** - * Force refresh the cache and return commits. + * Refresh the cache for a specific year. + * Clears the cache and re-fetches commits from the provider. * * @return Commit[] */ public function refresh(?int $year = null): array; - /** - * Get available years for filtering. - * - * @return int[] - */ - public function getAvailableYears(): array; - /** * Get the cache key for a specific year. * Useful for cache clearing commands. diff --git a/src/Service/DependencyDetectionService.php b/src/Service/DependencyDetectionService.php deleted file mode 100644 index ca7964d..0000000 --- a/src/Service/DependencyDetectionService.php +++ /dev/null @@ -1,115 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Service; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; - -class DependencyDetectionService -{ - private const CACHE_KEY_PREFIX = 'spiriit_commit_history_has_deps_'; - - /** - * @param string[] $dependencyFiles - */ - public function __construct( - private readonly ProviderInterface $provider, - private readonly CacheInterface $cache, - private readonly array $dependencyFiles, - private readonly bool $trackDependencyChanges, - ) { - } - - /** - * Detect dependency changes for a list of commits. - * Uses per-commit caching to avoid re-fetching file names. - * - * @param Commit[] $commits - * - * @return Commit[] - */ - public function detectForCommits(array $commits): array - { - if (!$this->trackDependencyChanges) { - return $commits; - } - - $result = []; - foreach ($commits as $commit) { - $hasDeps = $this->hasDependencyChanges($commit->id); - $result[] = $commit->withHasDependenciesChanges($hasDeps); - } - - return $result; - } - - /** - * Check if a commit has dependency changes. - * Result is cached per commit ID (never invalidates since commit ID is immutable). - */ - public function hasDependencyChanges(string $commitId): bool - { - if (!$this->trackDependencyChanges) { - return false; - } - - $cacheKey = self::CACHE_KEY_PREFIX.$commitId; - - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($commitId): bool { - // Cache forever (no TTL) since commit ID is immutable - $item->expiresAfter(null); - - return $this->checkCommitForDependencyFiles($commitId); - }); - } - - /** - * Clear the dependency detection cache for a specific commit. - */ - public function clearCache(string $commitId): void - { - $cacheKey = self::CACHE_KEY_PREFIX.$commitId; - $this->cache->delete($cacheKey); - } - - /** - * Check if any of the changed files in the commit are dependency files. - */ - private function checkCommitForDependencyFiles(string $commitId): bool - { - try { - $fileNames = $this->provider->getCommitFileNames($commitId); - } catch (\Throwable) { - return false; - } - - foreach ($fileNames as $fileName) { - $baseName = basename($fileName); - if (\in_array($baseName, $this->dependencyFiles, true)) { - return true; - } - } - - return false; - } - - /** - * Get the cache key prefix for dependency detection. - * Useful for cache clearing commands. - */ - public static function getCacheKeyPrefix(): string - { - return self::CACHE_KEY_PREFIX; - } -} diff --git a/src/Service/DiffParser/ComposerDiffParser.php b/src/Service/DiffParser/ComposerDiffParser.php deleted file mode 100644 index 3332c7d..0000000 --- a/src/Service/DiffParser/ComposerDiffParser.php +++ /dev/null @@ -1,174 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; - -class ComposerDiffParser implements DiffParserInterface -{ - private const SUPPORTED_FILES = ['composer.json', 'composer.lock']; - - private const NON_DEPENDENCY_KEYS = [ - 'name', - 'description', - 'type', - 'license', - 'minimum-stability', - 'prefer-stable', - 'autoload', - 'autoload-dev', - 'scripts', - 'config', - 'extra', - 'repositories', - ]; - - public function supports(string $filename): bool - { - $baseName = basename($filename); - - return \in_array($baseName, self::SUPPORTED_FILES, true); - } - - /** - * @return DependencyChange[] - */ - public function parse(string $diff, string $filename): array - { - $baseName = basename($filename); - - if ('composer.lock' === $baseName) { - return $this->parseComposerLock($diff, $filename); - } - - return $this->parseComposerJson($diff, $filename); - } - - /** - * @return DependencyChange[] - */ - private function parseComposerJson(string $diff, string $filename): array - { - $changes = []; - $lines = explode("\n", $diff); - - foreach ($lines as $line) { - // Match lines like: + "symfony/http-client": "^7.0", - // or - "symfony/http-client": "^6.4", - if (preg_match('/^([+-])\s*"([^"]+)":\s*"([^"]+)"/', $line, $matches)) { - $operation = $matches[1]; - $package = $matches[2]; - $version = $matches[3]; - - // Skip non-dependency keys - if (\in_array($package, self::NON_DEPENDENCY_KEYS, true)) { - continue; - } - - // Skip if it doesn't look like a package name (should contain /) - if (!str_contains($package, '/')) { - continue; - } - - if ('+' === $operation) { - if (!isset($changes[$package])) { - $changes[$package] = ['name' => $package, 'sourceFile' => $filename]; - } - $changes[$package]['newVersion'] = $version; - } else { - if (!isset($changes[$package])) { - $changes[$package] = ['name' => $package, 'sourceFile' => $filename]; - } - $changes[$package]['oldVersion'] = $version; - } - } - } - - return $this->buildDependencyChanges($changes); - } - - /** - * @return DependencyChange[] - */ - private function parseComposerLock(string $diff, string $filename): array - { - $changes = []; - $lines = explode("\n", $diff); - $currentPackage = null; - $currentOperation = null; - - foreach ($lines as $line) { - // Match package name: + "name": "symfony/http-client", - if (preg_match('/^([+-])\s*"name":\s*"([^"]+)"/', $line, $matches)) { - $currentOperation = $matches[1]; - $currentPackage = $matches[2]; - - if (!isset($changes[$currentPackage])) { - $changes[$currentPackage] = ['name' => $currentPackage, 'sourceFile' => $filename]; - } - } - // Match version: + "version": "v7.0.0", - elseif (null !== $currentPackage && preg_match('/^([+-])\s*"version":\s*"([^"]+)"/', $line, $matches)) { - $operation = $matches[1]; - $version = $matches[2]; - - if ('+' === $operation) { - $changes[$currentPackage]['newVersion'] = $version; - } else { - $changes[$currentPackage]['oldVersion'] = $version; - } - } - // Reset on chunk boundaries or closing braces that indicate package block end - elseif (str_starts_with($line, '@@') || preg_match('/^\s*\},?\s*$/', $line)) { - $currentPackage = null; - $currentOperation = null; - } - } - - return $this->buildDependencyChanges($changes); - } - - /** - * @param array $changes - * - * @return DependencyChange[] - */ - private function buildDependencyChanges(array $changes): array - { - $result = []; - - foreach ($changes as $change) { - $hasOld = !empty($change['oldVersion']); - $hasNew = !empty($change['newVersion']); - - if (!$hasOld && !$hasNew) { - continue; - } - - $type = match (true) { - $hasOld && $hasNew => DependencyChange::TYPE_UPDATED, - $hasNew => DependencyChange::TYPE_ADDED, - default => DependencyChange::TYPE_REMOVED, - }; - - $result[] = new DependencyChange( - name: $change['name'], - type: $type, - oldVersion: $change['oldVersion'] ?? null, - newVersion: $change['newVersion'] ?? null, - sourceFile: $change['sourceFile'], - ); - } - - return $result; - } -} diff --git a/src/Service/DiffParser/DiffParserInterface.php b/src/Service/DiffParser/DiffParserInterface.php deleted file mode 100644 index 200bbcf..0000000 --- a/src/Service/DiffParser/DiffParserInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; - -interface DiffParserInterface -{ - /** - * Check if this parser supports the given filename. - */ - public function supports(string $filename): bool; - - /** - * Parse diff content and return dependency changes. - * - * @return DependencyChange[] - */ - public function parse(string $diff, string $filename): array; -} diff --git a/src/Service/DiffParser/DiffParserRegistry.php b/src/Service/DiffParser/DiffParserRegistry.php deleted file mode 100644 index b8a1470..0000000 --- a/src/Service/DiffParser/DiffParserRegistry.php +++ /dev/null @@ -1,81 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; - -class DiffParserRegistry -{ - /** - * @var iterable - */ - private readonly iterable $parsers; - - /** - * @param iterable $parsers - */ - public function __construct(iterable $parsers) - { - $this->parsers = $parsers; - } - - /** - * Parse diffs from multiple files and return all dependency changes. - * - * @param array $diffs Map of filename => diff content - * - * @return DependencyChange[] - */ - public function parseAll(array $diffs): array - { - $changes = []; - - foreach ($diffs as $filename => $diff) { - $fileChanges = $this->parse($diff, $filename); - foreach ($fileChanges as $change) { - $changes[] = $change; - } - } - - return $changes; - } - - /** - * Parse a single file's diff content. - * - * @return DependencyChange[] - */ - public function parse(string $diff, string $filename): array - { - foreach ($this->parsers as $parser) { - if ($parser->supports($filename)) { - return $parser->parse($diff, $filename); - } - } - - return []; - } - - /** - * Check if any parser supports the given filename. - */ - public function supports(string $filename): bool - { - foreach ($this->parsers as $parser) { - if ($parser->supports($filename)) { - return true; - } - } - - return false; - } -} diff --git a/src/Service/DiffParser/PackageDiffParser.php b/src/Service/DiffParser/PackageDiffParser.php deleted file mode 100644 index 667fc4b..0000000 --- a/src/Service/DiffParser/PackageDiffParser.php +++ /dev/null @@ -1,180 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; - -class PackageDiffParser implements DiffParserInterface -{ - private const SUPPORTED_FILES = ['package.json', 'package-lock.json']; - - private const NON_DEPENDENCY_KEYS = [ - 'name', - 'version', - 'description', - 'main', - 'scripts', - 'repository', - 'keywords', - 'author', - 'license', - 'bugs', - 'homepage', - 'private', - 'engines', - 'browserslist', - ]; - - public function supports(string $filename): bool - { - $baseName = basename($filename); - - return \in_array($baseName, self::SUPPORTED_FILES, true); - } - - /** - * @return DependencyChange[] - */ - public function parse(string $diff, string $filename): array - { - $baseName = basename($filename); - - if ('package-lock.json' === $baseName) { - return $this->parsePackageLock($diff, $filename); - } - - return $this->parsePackageJson($diff, $filename); - } - - /** - * @return DependencyChange[] - */ - private function parsePackageJson(string $diff, string $filename): array - { - $changes = []; - $lines = explode("\n", $diff); - - foreach ($lines as $line) { - // Match lines like: + "react": "^18.0.0", - // or - "react": "^17.0.0", - if (preg_match('/^([+-])\s*"([^"]+)":\s*"([^"]+)"/', $line, $matches)) { - $operation = $matches[1]; - $package = $matches[2]; - $version = $matches[3]; - - // Skip non-dependency keys - if (\in_array($package, self::NON_DEPENDENCY_KEYS, true)) { - continue; - } - - // Skip if it looks like a URL or path - if (str_starts_with($version, 'http') || str_starts_with($version, 'file:') || str_starts_with($version, 'git')) { - continue; - } - - if ('+' === $operation) { - if (!isset($changes[$package])) { - $changes[$package] = ['name' => $package, 'sourceFile' => $filename]; - } - $changes[$package]['newVersion'] = $version; - } else { - if (!isset($changes[$package])) { - $changes[$package] = ['name' => $package, 'sourceFile' => $filename]; - } - $changes[$package]['oldVersion'] = $version; - } - } - } - - return $this->buildDependencyChanges($changes); - } - - /** - * @return DependencyChange[] - */ - private function parsePackageLock(string $diff, string $filename): array - { - $changes = []; - $lines = explode("\n", $diff); - $currentPackage = null; - - foreach ($lines as $line) { - // Match package entry: + "node_modules/lodash": { - // or: - "lodash": { - if (preg_match('/^([+-])\s*"(?:node_modules\/)?([^"]+)":\s*\{/', $line, $matches)) { - $package = $matches[2]; - - // Skip nested node_modules paths - if (str_contains($package, '/node_modules/')) { - continue; - } - - $currentPackage = $package; - if (!isset($changes[$currentPackage])) { - $changes[$currentPackage] = ['name' => $currentPackage, 'sourceFile' => $filename]; - } - } - // Match version in lock file - elseif (null !== $currentPackage && preg_match('/^([+-])\s*"version":\s*"([^"]+)"/', $line, $matches)) { - $operation = $matches[1]; - $version = $matches[2]; - - if ('+' === $operation) { - $changes[$currentPackage]['newVersion'] = $version; - } else { - $changes[$currentPackage]['oldVersion'] = $version; - } - } - // Reset on chunk boundaries - elseif (str_starts_with($line, '@@')) { - $currentPackage = null; - } - } - - return $this->buildDependencyChanges($changes); - } - - /** - * @param array $changes - * - * @return DependencyChange[] - */ - private function buildDependencyChanges(array $changes): array - { - $result = []; - - foreach ($changes as $change) { - $hasOld = !empty($change['oldVersion']); - $hasNew = !empty($change['newVersion']); - - if (!$hasOld && !$hasNew) { - continue; - } - - $type = match (true) { - $hasOld && $hasNew => DependencyChange::TYPE_UPDATED, - $hasNew => DependencyChange::TYPE_ADDED, - default => DependencyChange::TYPE_REMOVED, - }; - - $result[] = new DependencyChange( - name: $change['name'], - type: $type, - oldVersion: $change['oldVersion'] ?? null, - newVersion: $change['newVersion'] ?? null, - sourceFile: $change['sourceFile'], - ); - } - - return $result; - } -} diff --git a/src/Service/FeedFetcher.php b/src/Service/FeedFetcher.php deleted file mode 100644 index bbf42f0..0000000 --- a/src/Service/FeedFetcher.php +++ /dev/null @@ -1,99 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Spiriit\Bundle\CommitHistoryBundle\Service; - -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; - -class FeedFetcher implements FeedFetcherInterface -{ - public function __construct( - private readonly ProviderInterface $provider, - private readonly CacheInterface $cache, - private readonly int $cacheTtl = 3600, - private readonly int $availableYearsCount = 6, - private readonly ?DependencyDetectionService $dependencyDetectionService = null, - ) { - } - - /** - * @return Commit[] - */ - public function fetch(?int $year = null): array - { - $year = $year ?? (int) date('Y'); - [$since, $until] = $this->getYearDateRange($year); - - $commits = $this->cache->get($this->getCacheKey($year), function (ItemInterface $item) use ($since, $until): array { - $commits = $this->provider->getCommits($since, $until); - - if (empty($commits)) { - $item->expiresAfter(0); - } else { - $item->expiresAfter($this->cacheTtl); - } - - return $commits; - }); - - // Detect dependency changes for each commit (uses per-commit caching) - if (null !== $this->dependencyDetectionService) { - $commits = $this->dependencyDetectionService->detectForCommits($commits); - } - - return $commits; - } - - /** - * @return Commit[] - */ - public function refresh(?int $year = null): array - { - $year = $year ?? (int) date('Y'); - $this->cache->delete($this->getCacheKey($year)); - - return $this->fetch($year); - } - - /** - * @return int[] - */ - public function getAvailableYears(): array - { - $currentYear = (int) date('Y'); - $years = []; - - for ($i = 0; $i < $this->availableYearsCount; ++$i) { - $years[] = $currentYear - $i; - } - - return $years; - } - - public function getCacheKey(int $year): string - { - return 'spiriit_commit_history_feed_'.md5(\get_class($this->provider)).'_'.$year; - } - - /** - * @return array{\DateTimeImmutable, \DateTimeImmutable} - */ - private function getYearDateRange(int $year): array - { - $since = new \DateTimeImmutable(\sprintf('%d-01-01T00:00:00+00:00', $year)); - $until = new \DateTimeImmutable(\sprintf('%d-12-31T23:59:59+00:00', $year)); - - return [$since, $until]; - } -} diff --git a/tests/Functional/Controller/DependenciesChangesControllerTest.php b/tests/Functional/Controller/DependenciesChangesControllerTest.php index 2dbbdb2..5b836fb 100644 --- a/tests/Functional/Controller/DependenciesChangesControllerTest.php +++ b/tests/Functional/Controller/DependenciesChangesControllerTest.php @@ -14,10 +14,10 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Spiriit\Bundle\CommitHistoryBundle\Controller\DependenciesChangesController; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\ComposerDiffParser; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\DiffParserRegistry; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\PackageDiffParser; +use Spiriit\CommitHistory\DiffParser\ComposerDiffParser; +use Spiriit\CommitHistory\DiffParser\DiffParserRegistry; +use Spiriit\CommitHistory\DiffParser\PackageDiffParser; +use Spiriit\CommitHistory\Provider\ProviderInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; diff --git a/tests/Functional/Controller/TimelineControllerTest.php b/tests/Functional/Controller/TimelineControllerTest.php index 41dd907..666d5fd 100644 --- a/tests/Functional/Controller/TimelineControllerTest.php +++ b/tests/Functional/Controller/TimelineControllerTest.php @@ -13,8 +13,8 @@ use PHPUnit\Framework\TestCase; use Spiriit\Bundle\CommitHistoryBundle\Controller\TimelineController; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Twig\Environment; diff --git a/tests/Functional/DependencyInjection/SpiriitCommitHistoryExtensionTest.php b/tests/Functional/DependencyInjection/SpiriitCommitHistoryExtensionTest.php index 95e4175..aa49d79 100644 --- a/tests/Functional/DependencyInjection/SpiriitCommitHistoryExtensionTest.php +++ b/tests/Functional/DependencyInjection/SpiriitCommitHistoryExtensionTest.php @@ -13,8 +13,8 @@ use PHPUnit\Framework\TestCase; use Spiriit\Bundle\CommitHistoryBundle\DependencyInjection\SpiriitCommitHistoryExtension; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\CommitHistory\Provider\ProviderInterface; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; class SpiriitCommitHistoryExtensionTest extends TestCase diff --git a/tests/Unit/Command/ClearCacheCommandTest.php b/tests/Unit/Command/ClearCacheCommandTest.php index 569e042..e69be0a 100644 --- a/tests/Unit/Command/ClearCacheCommandTest.php +++ b/tests/Unit/Command/ClearCacheCommandTest.php @@ -14,8 +14,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Spiriit\Bundle\CommitHistoryBundle\Command\ClearCacheCommand; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingFeedFetcherInterface; +use Spiriit\CommitHistory\DTO\Commit; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -23,12 +23,12 @@ class ClearCacheCommandTest extends TestCase { private ArrayAdapter $cache; - private FeedFetcherInterface&MockObject $feedFetcher; + private CachingFeedFetcherInterface&MockObject $feedFetcher; protected function setUp(): void { $this->cache = new ArrayAdapter(); - $this->feedFetcher = $this->createMock(FeedFetcherInterface::class); + $this->feedFetcher = $this->createMock(CachingFeedFetcherInterface::class); } public function testExecuteClearsAllYearsWithAllOption(): void diff --git a/tests/Unit/Command/RefreshCacheCommandTest.php b/tests/Unit/Command/RefreshCacheCommandTest.php index 3598bec..467bafb 100644 --- a/tests/Unit/Command/RefreshCacheCommandTest.php +++ b/tests/Unit/Command/RefreshCacheCommandTest.php @@ -14,8 +14,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Spiriit\Bundle\CommitHistoryBundle\Command\RefreshCacheCommand; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingFeedFetcherInterface; +use Spiriit\CommitHistory\DTO\Commit; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -23,12 +23,12 @@ class RefreshCacheCommandTest extends TestCase { private ArrayAdapter $cache; - private FeedFetcherInterface&MockObject $feedFetcher; + private CachingFeedFetcherInterface&MockObject $feedFetcher; protected function setUp(): void { $this->cache = new ArrayAdapter(); - $this->feedFetcher = $this->createMock(FeedFetcherInterface::class); + $this->feedFetcher = $this->createMock(CachingFeedFetcherInterface::class); } public function testExecuteRefreshesCurrentYearByDefault(): void diff --git a/tests/Unit/DTO/CommitTest.php b/tests/Unit/DTO/CommitTest.php index 19a2e4f..9240106 100644 --- a/tests/Unit/DTO/CommitTest.php +++ b/tests/Unit/DTO/CommitTest.php @@ -12,7 +12,7 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\DTO; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; +use Spiriit\CommitHistory\DTO\Commit; class CommitTest extends TestCase { diff --git a/tests/Unit/DTO/DependencyChangeTest.php b/tests/Unit/DTO/DependencyChangeTest.php index e2fbd6f..18e1866 100644 --- a/tests/Unit/DTO/DependencyChangeTest.php +++ b/tests/Unit/DTO/DependencyChangeTest.php @@ -12,7 +12,7 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\DTO; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; +use Spiriit\CommitHistory\DTO\DependencyChange; class DependencyChangeTest extends TestCase { diff --git a/tests/Unit/Provider/Github/CommitParserTest.php b/tests/Unit/Provider/Github/CommitParserTest.php index 1175406..1090d48 100644 --- a/tests/Unit/Provider/Github/CommitParserTest.php +++ b/tests/Unit/Provider/Github/CommitParserTest.php @@ -12,8 +12,8 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Provider\Github; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Github\CommitParser; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Provider\Github\CommitParser; class CommitParserTest extends TestCase { diff --git a/tests/Unit/Provider/Github/ProviderTest.php b/tests/Unit/Provider/Github/ProviderTest.php index ed0e8aa..a805ddc 100644 --- a/tests/Unit/Provider/Github/ProviderTest.php +++ b/tests/Unit/Provider/Github/ProviderTest.php @@ -13,11 +13,10 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Github\CommitParser; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Github\Provider; -use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; +use Spiriit\CommitHistory\Contract\HttpClientInterface; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Provider\Github\CommitParser; +use Spiriit\CommitHistory\Provider\Github\GithubProvider; class ProviderTest extends TestCase { @@ -34,16 +33,16 @@ public function testGetCommitsReturnsCommits(): void { $json = file_get_contents(__DIR__.'/../../../Fixtures/github_commits.json'); - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn(json_decode($json, true)); - $response->method('getHeaders')->willReturn([]); - $this->httpClient ->expects($this->once()) ->method('request') - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => $json, + ]); - $provider = new Provider( + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', @@ -59,24 +58,24 @@ public function testGetCommitsReturnsCommits(): void public function testGetCommitsWithToken(): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn([]); - $response->method('getHeaders')->willReturn([]); - $this->httpClient ->expects($this->once()) ->method('request') ->with( 'GET', $this->stringContains('/repos/'), - $this->callback(function (array $options): bool { - return isset($options['headers']['Authorization']) - && 'Bearer ghp_xxxx' === $options['headers']['Authorization']; + $this->callback(function (array $headers): bool { + return isset($headers['Authorization']) + && 'Bearer ghp_xxxx' === $headers['Authorization']; }) ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => '[]', + ]); - $provider = new Provider( + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', @@ -90,24 +89,21 @@ public function testGetCommitsWithToken(): void public function testGetCommitsWithRef(): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn([]); - $response->method('getHeaders')->willReturn([]); - $this->httpClient ->expects($this->once()) ->method('request') ->with( 'GET', - $this->anything(), - $this->callback(function (array $options): bool { - return isset($options['query']['sha']) - && 'develop' === $options['query']['sha']; - }) + $this->stringContains('sha=develop'), + $this->anything() ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => '[]', + ]); - $provider = new Provider( + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', @@ -126,22 +122,23 @@ public function testGetCommitsPaginates(): void ['sha' => 'abc123', 'html_url' => 'https://example.com', 'commit' => ['message' => 'test', 'author' => ['name' => 'Test', 'email' => 'test@test.com', 'date' => '2025-01-01T00:00:00Z']]], ]; - $response1 = $this->createMock(ResponseInterface::class); - $response1->method('toArray')->willReturn($commits); - $response1->method('getHeaders')->willReturn([ - 'link' => ['; rel="next"'], - ]); - - $response2 = $this->createMock(ResponseInterface::class); - $response2->method('toArray')->willReturn($commits); - $response2->method('getHeaders')->willReturn([]); - $this->httpClient ->expects($this->exactly(2)) ->method('request') - ->willReturnOnConsecutiveCalls($response1, $response2); - - $provider = new Provider( + ->willReturnOnConsecutiveCalls( + [ + 'status' => 200, + 'headers' => ['link' => ['; rel="next"']], + 'body' => json_encode($commits), + ], + [ + 'status' => 200, + 'headers' => [], + 'body' => json_encode($commits), + ] + ); + + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', @@ -156,10 +153,6 @@ public function testGetCommitsPaginates(): void public function testGetCommitsWithDateRange(): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn([]); - $response->method('getHeaders')->willReturn([]); - $since = new \DateTimeImmutable('2025-01-01T00:00:00+00:00'); $until = new \DateTimeImmutable('2025-12-31T23:59:59+00:00'); @@ -168,17 +161,19 @@ public function testGetCommitsWithDateRange(): void ->method('request') ->with( 'GET', - $this->anything(), - $this->callback(function (array $options) use ($since, $until): bool { - return isset($options['query']['since']) - && $options['query']['since'] === $since->format('c') - && isset($options['query']['until']) - && $options['query']['until'] === $until->format('c'); - }) + $this->callback(function (string $url) use ($since, $until): bool { + return str_contains($url, 'since='.urlencode($since->format('c'))) + && str_contains($url, 'until='.urlencode($until->format('c'))); + }), + $this->anything() ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => '[]', + ]); - $provider = new Provider( + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', @@ -199,9 +194,6 @@ public function testGetCommitFileNames(): void ], ]; - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn($commitResponse); - $this->httpClient ->expects($this->once()) ->method('request') @@ -210,9 +202,13 @@ public function testGetCommitFileNames(): void $this->stringContains('/commits/abc123'), $this->anything() ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => json_encode($commitResponse), + ]); - $provider = new Provider( + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', @@ -231,23 +227,24 @@ public function testGetCommitFileNamesWithToken(): void { $commitResponse = ['sha' => 'abc123', 'files' => []]; - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn($commitResponse); - $this->httpClient ->expects($this->once()) ->method('request') ->with( 'GET', $this->anything(), - $this->callback(function (array $options): bool { - return isset($options['headers']['Authorization']) - && 'Bearer ghp_xxxx' === $options['headers']['Authorization']; + $this->callback(function (array $headers): bool { + return isset($headers['Authorization']) + && 'Bearer ghp_xxxx' === $headers['Authorization']; }) ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => json_encode($commitResponse), + ]); - $provider = new Provider( + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', @@ -270,15 +267,16 @@ public function testGetCommitDiff(): void ], ]; - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn($commitResponse); - $this->httpClient ->expects($this->once()) ->method('request') - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => json_encode($commitResponse), + ]); - $provider = new Provider( + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', @@ -304,15 +302,16 @@ public function testGetCommitDiffExcludesFilesWithoutPatch(): void ], ]; - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn($commitResponse); - $this->httpClient ->expects($this->once()) ->method('request') - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => json_encode($commitResponse), + ]); - $provider = new Provider( + $provider = new GithubProvider( $this->httpClient, $this->parser, 'https://api.github.com', diff --git a/tests/Unit/Provider/Gitlab/CommitParserTest.php b/tests/Unit/Provider/Gitlab/CommitParserTest.php index 78c8907..d2f5104 100644 --- a/tests/Unit/Provider/Gitlab/CommitParserTest.php +++ b/tests/Unit/Provider/Gitlab/CommitParserTest.php @@ -12,8 +12,8 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Provider\Gitlab; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Gitlab\CommitParser; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Provider\Gitlab\CommitParser; class CommitParserTest extends TestCase { diff --git a/tests/Unit/Provider/Gitlab/ProviderTest.php b/tests/Unit/Provider/Gitlab/ProviderTest.php index 8febcaa..92582bb 100644 --- a/tests/Unit/Provider/Gitlab/ProviderTest.php +++ b/tests/Unit/Provider/Gitlab/ProviderTest.php @@ -13,11 +13,10 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Gitlab\CommitParser; -use Spiriit\Bundle\CommitHistoryBundle\Provider\Gitlab\Provider; -use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; +use Spiriit\CommitHistory\Contract\HttpClientInterface; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Provider\Gitlab\CommitParser; +use Spiriit\CommitHistory\Provider\Gitlab\GitlabProvider; class ProviderTest extends TestCase { @@ -34,15 +33,16 @@ public function testGetCommitsReturnsCommits(): void { $json = file_get_contents(__DIR__.'/../../../Fixtures/gitlab_commits.json'); - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn(json_decode($json, true)); - $this->httpClient ->expects($this->once()) ->method('request') - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => $json, + ]); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', @@ -57,23 +57,24 @@ public function testGetCommitsReturnsCommits(): void public function testGetCommitsWithToken(): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn([]); - $this->httpClient ->expects($this->once()) ->method('request') ->with( 'GET', $this->stringContains('/api/v4/projects/'), - $this->callback(function (array $options): bool { - return isset($options['headers']['PRIVATE-TOKEN']) - && 'glpat-xxxx' === $options['headers']['PRIVATE-TOKEN']; + $this->callback(function (array $headers): bool { + return isset($headers['PRIVATE-TOKEN']) + && 'glpat-xxxx' === $headers['PRIVATE-TOKEN']; }) ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => '[]', + ]); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', @@ -86,23 +87,21 @@ public function testGetCommitsWithToken(): void public function testGetCommitsWithRef(): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn([]); - $this->httpClient ->expects($this->once()) ->method('request') ->with( 'GET', - $this->anything(), - $this->callback(function (array $options): bool { - return isset($options['query']['ref_name']) - && 'develop' === $options['query']['ref_name']; - }) + $this->stringContains('ref_name=develop'), + $this->anything() ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => '[]', + ]); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', @@ -127,18 +126,15 @@ public function testGetCommitsPaginates(): void 'web_url' => 'https://example.com', ]); - $response1 = $this->createMock(ResponseInterface::class); - $response1->method('toArray')->willReturn($commits); - - $response2 = $this->createMock(ResponseInterface::class); - $response2->method('toArray')->willReturn([]); - $this->httpClient ->expects($this->exactly(2)) ->method('request') - ->willReturnOnConsecutiveCalls($response1, $response2); + ->willReturnOnConsecutiveCalls( + ['status' => 200, 'headers' => [], 'body' => json_encode($commits)], + ['status' => 200, 'headers' => [], 'body' => '[]'] + ); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', @@ -152,9 +148,6 @@ public function testGetCommitsPaginates(): void public function testGetCommitsWithDateRange(): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn([]); - $since = new \DateTimeImmutable('2025-01-01T00:00:00+00:00'); $until = new \DateTimeImmutable('2025-12-31T23:59:59+00:00'); @@ -163,17 +156,19 @@ public function testGetCommitsWithDateRange(): void ->method('request') ->with( 'GET', - $this->anything(), - $this->callback(function (array $options) use ($since, $until): bool { - return isset($options['query']['since']) - && $options['query']['since'] === $since->format('c') - && isset($options['query']['until']) - && $options['query']['until'] === $until->format('c'); - }) + $this->callback(function (string $url) use ($since, $until): bool { + return str_contains($url, 'since='.urlencode($since->format('c'))) + && str_contains($url, 'until='.urlencode($until->format('c'))); + }), + $this->anything() ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => '[]', + ]); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', @@ -190,9 +185,6 @@ public function testGetCommitFileNames(): void ['new_path' => 'src/Controller.php', 'old_path' => 'src/Controller.php', 'diff' => 'another diff'], ]; - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn($diffResponse); - $this->httpClient ->expects($this->once()) ->method('request') @@ -201,9 +193,13 @@ public function testGetCommitFileNames(): void $this->stringContains('/repository/commits/abc123/diff'), $this->anything() ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => json_encode($diffResponse), + ]); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', @@ -219,23 +215,24 @@ public function testGetCommitFileNames(): void public function testGetCommitFileNamesWithToken(): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn([]); - $this->httpClient ->expects($this->once()) ->method('request') ->with( 'GET', $this->anything(), - $this->callback(function (array $options): bool { - return isset($options['headers']['PRIVATE-TOKEN']) - && 'glpat-xxxx' === $options['headers']['PRIVATE-TOKEN']; + $this->callback(function (array $headers): bool { + return isset($headers['PRIVATE-TOKEN']) + && 'glpat-xxxx' === $headers['PRIVATE-TOKEN']; }) ) - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => '[]', + ]); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', @@ -254,15 +251,16 @@ public function testGetCommitDiff(): void ['new_path' => 'README.md', 'old_path' => 'README.md', 'diff' => 'readme diff'], ]; - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn($diffResponse); - $this->httpClient ->expects($this->once()) ->method('request') - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => json_encode($diffResponse), + ]); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', @@ -283,15 +281,16 @@ public function testGetCommitDiffUsesNewPathOverOldPath(): void ['new_path' => 'renamed.json', 'old_path' => 'original.json', 'diff' => 'diff content'], ]; - $response = $this->createMock(ResponseInterface::class); - $response->method('toArray')->willReturn($diffResponse); - $this->httpClient ->expects($this->once()) ->method('request') - ->willReturn($response); + ->willReturn([ + 'status' => 200, + 'headers' => [], + 'body' => json_encode($diffResponse), + ]); - $provider = new Provider( + $provider = new GitlabProvider( $this->httpClient, $this->parser, 'https://gitlab.example.com', diff --git a/tests/Unit/Service/DependencyDetectionServiceTest.php b/tests/Unit/Service/DependencyDetectionServiceTest.php index d00e244..db7100d 100644 --- a/tests/Unit/Service/DependencyDetectionServiceTest.php +++ b/tests/Unit/Service/DependencyDetectionServiceTest.php @@ -13,9 +13,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Spiriit\Bundle\CommitHistoryBundle\Service\DependencyDetectionService; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingDependencyDetectionService; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Provider\ProviderInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; class DependencyDetectionServiceTest extends TestCase @@ -37,11 +37,11 @@ public function testHasDependencyChangesReturnsTrueWhenDependencyFileModified(): ->with('abc123') ->willReturn(['src/Controller.php', 'composer.json', 'README.md']); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json', 'composer.lock'], true, + $this->cache, ); $result = $service->hasDependencyChanges('abc123'); @@ -57,11 +57,11 @@ public function testHasDependencyChangesReturnsFalseWhenNoDependencyFileModified ->with('abc123') ->willReturn(['src/Controller.php', 'README.md']); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json', 'composer.lock'], true, + $this->cache, ); $result = $service->hasDependencyChanges('abc123'); @@ -77,11 +77,11 @@ public function testHasDependencyChangesHandlesPathsWithDirectories(): void ->with('abc123') ->willReturn(['src/Controller.php', 'packages/my-package/composer.json']); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json', 'composer.lock'], true, + $this->cache, ); $result = $service->hasDependencyChanges('abc123'); @@ -95,11 +95,11 @@ public function testHasDependencyChangesReturnsFalseWhenTrackingDisabled(): void ->expects($this->never()) ->method('getCommitFileNames'); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json'], false, + $this->cache, ); $result = $service->hasDependencyChanges('abc123'); @@ -115,11 +115,11 @@ public function testHasDependencyChangesCachesResult(): void ->with('abc123') ->willReturn(['composer.json']); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json'], true, + $this->cache, ); // First call - fetches from provider @@ -139,11 +139,11 @@ public function testHasDependencyChangesReturnsFalseOnProviderError(): void ->method('getCommitFileNames') ->willThrowException(new \RuntimeException('API error')); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json'], true, + $this->cache, ); $result = $service->hasDependencyChanges('abc123'); @@ -165,11 +165,11 @@ public function testDetectForCommitsUpdatesAllCommits(): void return 'abc123' === $commitId ? ['composer.json'] : ['README.md']; }); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json'], true, + $this->cache, ); $result = $service->detectForCommits($commits); @@ -189,11 +189,11 @@ public function testDetectForCommitsReturnsUnmodifiedCommitsWhenDisabled(): void ->expects($this->never()) ->method('getCommitFileNames'); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json'], false, + $this->cache, ); $result = $service->detectForCommits($commits); @@ -202,37 +202,9 @@ public function testDetectForCommitsReturnsUnmodifiedCommitsWhenDisabled(): void $this->assertFalse($result[0]->hasDependenciesChanges); } - public function testClearCacheDeletesCacheForCommit(): void - { - // Provider should be called twice - once initially, once after cache clear - $this->provider - ->expects($this->exactly(2)) - ->method('getCommitFileNames') - ->with('abc123') - ->willReturnOnConsecutiveCalls(['composer.json'], ['README.md']); - - $service = new DependencyDetectionService( - $this->provider, - $this->cache, - ['composer.json'], - true, - ); - - // First call - populates cache, returns true (composer.json found) - $result1 = $service->hasDependencyChanges('abc123'); - $this->assertTrue($result1); - - // Clear cache - $service->clearCache('abc123'); - - // Next call should fetch from provider again, returns false (README.md not a dependency file) - $result2 = $service->hasDependencyChanges('abc123'); - $this->assertFalse($result2); - } - public function testGetCacheKeyPrefix(): void { - $prefix = DependencyDetectionService::getCacheKeyPrefix(); + $prefix = CachingDependencyDetectionService::getCacheKeyPrefix(); $this->assertSame('spiriit_commit_history_has_deps_', $prefix); } @@ -247,11 +219,11 @@ public function testSupportsMultipleDependencyFileTypes(): void ['composer.lock'], ); - $service = new DependencyDetectionService( + $service = new CachingDependencyDetectionService( $this->provider, - $this->cache, ['composer.json', 'composer.lock', 'package.json', 'package-lock.json'], true, + $this->cache, ); $this->assertTrue($service->hasDependencyChanges('commit1')); diff --git a/tests/Unit/Service/DiffParser/ComposerDiffParserTest.php b/tests/Unit/Service/DiffParser/ComposerDiffParserTest.php index fb0e61d..a44d599 100644 --- a/tests/Unit/Service/DiffParser/ComposerDiffParserTest.php +++ b/tests/Unit/Service/DiffParser/ComposerDiffParserTest.php @@ -12,8 +12,8 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Service\DiffParser; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\ComposerDiffParser; +use Spiriit\CommitHistory\DiffParser\ComposerDiffParser; +use Spiriit\CommitHistory\DTO\DependencyChange; class ComposerDiffParserTest extends TestCase { diff --git a/tests/Unit/Service/DiffParser/DiffParserRegistryTest.php b/tests/Unit/Service/DiffParser/DiffParserRegistryTest.php index 21ad5a4..b5aca40 100644 --- a/tests/Unit/Service/DiffParser/DiffParserRegistryTest.php +++ b/tests/Unit/Service/DiffParser/DiffParserRegistryTest.php @@ -12,10 +12,10 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Service\DiffParser; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\ComposerDiffParser; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\DiffParserRegistry; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\PackageDiffParser; +use Spiriit\CommitHistory\DiffParser\ComposerDiffParser; +use Spiriit\CommitHistory\DiffParser\DiffParserRegistry; +use Spiriit\CommitHistory\DiffParser\PackageDiffParser; +use Spiriit\CommitHistory\DTO\DependencyChange; class DiffParserRegistryTest extends TestCase { diff --git a/tests/Unit/Service/DiffParser/PackageDiffParserTest.php b/tests/Unit/Service/DiffParser/PackageDiffParserTest.php index c545c99..1ec7b9b 100644 --- a/tests/Unit/Service/DiffParser/PackageDiffParserTest.php +++ b/tests/Unit/Service/DiffParser/PackageDiffParserTest.php @@ -12,8 +12,8 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Service\DiffParser; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; -use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\PackageDiffParser; +use Spiriit\CommitHistory\DiffParser\PackageDiffParser; +use Spiriit\CommitHistory\DTO\DependencyChange; class PackageDiffParserTest extends TestCase { diff --git a/tests/Unit/Service/FeedFetcherTest.php b/tests/Unit/Service/FeedFetcherTest.php index 2f6384b..245aa89 100644 --- a/tests/Unit/Service/FeedFetcherTest.php +++ b/tests/Unit/Service/FeedFetcherTest.php @@ -12,9 +12,9 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Service; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\DTO\Commit; -use Spiriit\Bundle\CommitHistoryBundle\Provider\ProviderInterface; -use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcher; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingFeedFetcher; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; class FeedFetcherTest extends TestCase @@ -32,12 +32,12 @@ public function testFetchReturnsCommits(): void new Commit('abc123', 'Test commit', new \DateTimeImmutable(), 'Author', 'https://example.com'), ]; - $provider = $this->createMock(ProviderInterface::class); - $provider->expects($this->once()) - ->method('getCommits') + $innerFetcher = $this->createMock(FeedFetcherInterface::class); + $innerFetcher->expects($this->once()) + ->method('fetch') ->willReturn($commits); - $fetcher = new FeedFetcher($provider, $this->cache, 3600); + $fetcher = new CachingFeedFetcher($innerFetcher, $this->cache, 3600, 'test_provider_hash'); $result = $fetcher->fetch(); @@ -51,14 +51,14 @@ public function testFetchUsesCacheOnSecondCall(): void new Commit('abc123', 'Test commit', new \DateTimeImmutable(), 'Author', 'https://example.com'), ]; - $provider = $this->createMock(ProviderInterface::class); - $provider->expects($this->once()) - ->method('getCommits') + $innerFetcher = $this->createMock(FeedFetcherInterface::class); + $innerFetcher->expects($this->once()) + ->method('fetch') ->willReturn($commits); - $fetcher = new FeedFetcher($provider, $this->cache, 3600); + $fetcher = new CachingFeedFetcher($innerFetcher, $this->cache, 3600, 'test_provider_hash'); - // First call - fetches from provider + // First call - fetches from inner fetcher $result1 = $fetcher->fetch(); // Second call - should use cache @@ -73,12 +73,12 @@ public function testRefreshReturnsCommits(): void new Commit('abc123', 'Test commit', new \DateTimeImmutable(), 'Author', 'https://example.com'), ]; - $provider = $this->createMock(ProviderInterface::class); - $provider->expects($this->once()) - ->method('getCommits') + $innerFetcher = $this->createMock(FeedFetcherInterface::class); + $innerFetcher->expects($this->once()) + ->method('fetch') ->willReturn($commits); - $fetcher = new FeedFetcher($provider, $this->cache, 3600); + $fetcher = new CachingFeedFetcher($innerFetcher, $this->cache, 3600, 'test_provider_hash'); $result = $fetcher->refresh(); @@ -95,12 +95,12 @@ public function testRefreshInvalidatesCache(): void new Commit('def456', 'New commit', new \DateTimeImmutable(), 'Author', 'https://example.com'), ]; - $provider = $this->createMock(ProviderInterface::class); - $provider->expects($this->exactly(2)) - ->method('getCommits') + $innerFetcher = $this->createMock(FeedFetcherInterface::class); + $innerFetcher->expects($this->exactly(2)) + ->method('fetch') ->willReturnOnConsecutiveCalls($commits1, $commits2); - $fetcher = new FeedFetcher($provider, $this->cache, 3600); + $fetcher = new CachingFeedFetcher($innerFetcher, $this->cache, 3600, 'test_provider_hash'); // First call - populates cache $result1 = $fetcher->fetch(); @@ -111,50 +111,6 @@ public function testRefreshInvalidatesCache(): void $this->assertSame('def456', $result2[0]->id); } - public function testFetchDoesNotCacheEmptyResult(): void - { - $provider = $this->createMock(ProviderInterface::class); - $provider->expects($this->exactly(2)) - ->method('getCommits') - ->willReturn([]); - - $fetcher = new FeedFetcher($provider, $this->cache, 3600); - - // First call - returns empty, should not cache - $result1 = $fetcher->fetch(); - $this->assertEmpty($result1); - - // Second call - should call provider again (not use cache) - $result2 = $fetcher->fetch(); - $this->assertEmpty($result2); - } - - public function testFetchWithYearPassesDateRangeToProvider(): void - { - $commits = [ - new Commit('abc123', 'Test commit', new \DateTimeImmutable(), 'Author', 'https://example.com'), - ]; - - $provider = $this->createMock(ProviderInterface::class); - $provider->expects($this->once()) - ->method('getCommits') - ->with( - $this->callback(function (\DateTimeImmutable $since): bool { - return '2024-01-01' === $since->format('Y-m-d'); - }), - $this->callback(function (\DateTimeImmutable $until): bool { - return '2024-12-31' === $until->format('Y-m-d'); - }) - ) - ->willReturn($commits); - - $fetcher = new FeedFetcher($provider, $this->cache, 3600); - - $result = $fetcher->fetch(2024); - - $this->assertCount(1, $result); - } - public function testFetchCachesPerYear(): void { $commits2024 = [ @@ -164,50 +120,56 @@ public function testFetchCachesPerYear(): void new Commit('def456', 'Commit 2025', new \DateTimeImmutable(), 'Author', 'https://example.com'), ]; - $provider = $this->createMock(ProviderInterface::class); - $provider->expects($this->exactly(2)) - ->method('getCommits') + $innerFetcher = $this->createMock(FeedFetcherInterface::class); + $innerFetcher->expects($this->exactly(2)) + ->method('fetch') ->willReturnOnConsecutiveCalls($commits2024, $commits2025); - $fetcher = new FeedFetcher($provider, $this->cache, 3600); + $fetcher = new CachingFeedFetcher($innerFetcher, $this->cache, 3600, 'test_provider_hash'); // Fetch 2024 $result2024 = $fetcher->fetch(2024); $this->assertSame('abc123', $result2024[0]->id); - // Fetch 2025 - should call provider again (different year = different cache key) + // Fetch 2025 - should call inner fetcher again (different year = different cache key) $result2025 = $fetcher->fetch(2025); $this->assertSame('def456', $result2025[0]->id); - // Fetch 2024 again - should use cache (provider not called) + // Fetch 2024 again - should use cache (inner fetcher not called) $result2024Again = $fetcher->fetch(2024); $this->assertSame('abc123', $result2024Again[0]->id); } - public function testGetAvailableYearsReturnsLastSixYearsByDefault(): void + public function testGetAvailableYearsDelegatesToInner(): void { - $provider = $this->createMock(ProviderInterface::class); - $fetcher = new FeedFetcher($provider, $this->cache, 3600); + $currentYear = (int) date('Y'); + $expectedYears = [$currentYear, $currentYear - 1, $currentYear - 2]; + + $innerFetcher = $this->createMock(FeedFetcherInterface::class); + $innerFetcher->expects($this->once()) + ->method('getAvailableYears') + ->willReturn($expectedYears); + + $fetcher = new CachingFeedFetcher($innerFetcher, $this->cache, 3600, 'test_provider_hash'); $years = $fetcher->getAvailableYears(); - $currentYear = (int) date('Y'); - $this->assertCount(6, $years); - $this->assertSame($currentYear, $years[0]); - $this->assertSame($currentYear - 5, $years[5]); + $this->assertSame($expectedYears, $years); } - public function testGetAvailableYearsWithCustomCount(): void + public function testGetCacheKeyReturnsUniqueKeyPerYearAndProvider(): void { - $provider = $this->createMock(ProviderInterface::class); - $fetcher = new FeedFetcher($provider, $this->cache, 3600, 10); + $innerFetcher = $this->createMock(FeedFetcherInterface::class); - $years = $fetcher->getAvailableYears(); - $currentYear = (int) date('Y'); + $fetcher = new CachingFeedFetcher($innerFetcher, $this->cache, 3600, 'my_provider_hash'); + + $key2024 = $fetcher->getCacheKey(2024); + $key2025 = $fetcher->getCacheKey(2025); - $this->assertCount(10, $years); - $this->assertSame($currentYear, $years[0]); - $this->assertSame($currentYear - 9, $years[9]); + $this->assertStringContainsString('my_provider_hash', $key2024); + $this->assertStringContainsString('2024', $key2024); + $this->assertStringContainsString('2025', $key2025); + $this->assertNotSame($key2024, $key2025); } public function testRefreshWithYearRefreshesOnlyThatYear(): void @@ -216,20 +178,13 @@ public function testRefreshWithYearRefreshesOnlyThatYear(): void new Commit('abc123', 'Test commit', new \DateTimeImmutable(), 'Author', 'https://example.com'), ]; - $provider = $this->createMock(ProviderInterface::class); - $provider->expects($this->once()) - ->method('getCommits') - ->with( - $this->callback(function (\DateTimeImmutable $since): bool { - return '2024-01-01' === $since->format('Y-m-d'); - }), - $this->callback(function (\DateTimeImmutable $until): bool { - return '2024-12-31' === $until->format('Y-m-d'); - }) - ) + $innerFetcher = $this->createMock(FeedFetcherInterface::class); + $innerFetcher->expects($this->once()) + ->method('fetch') + ->with(2024) ->willReturn($commits); - $fetcher = new FeedFetcher($provider, $this->cache, 3600); + $fetcher = new CachingFeedFetcher($innerFetcher, $this->cache, 3600, 'test_provider_hash'); $result = $fetcher->refresh(2024);