From 56c2415281efb3abc6d93406a11b3c5112f06ce2 Mon Sep 17 00:00:00 2001 From: Romain MILLAN Date: Mon, 12 Jan 2026 14:45:02 +0100 Subject: [PATCH 1/3] WIP --- composer.json | 7 + src/Adapter/SymfonyCacheAdapter.php | 40 ++++ src/Adapter/SymfonyHttpClientAdapter.php | 41 ++++ src/Command/ClearCacheCommand.php | 4 +- src/Command/RefreshCacheCommand.php | 6 +- .../DependenciesChangesController.php | 6 +- src/Controller/TimelineController.php | 2 +- src/DTO/Commit.php | 39 ---- src/DTO/DependencyChange.php | 28 --- .../SpiriitCommitHistoryExtension.php | 2 +- src/Provider/CommitParserInterface.php | 22 --- src/Provider/Github/CommitParser.php | 43 ----- src/Provider/Github/Provider.php | 147 -------------- src/Provider/Gitlab/CommitParser.php | 33 ---- src/Provider/Gitlab/Provider.php | 136 ------------- src/Provider/ProviderInterface.php | 36 ---- src/Resources/config/services.php | 104 +++++----- src/Service/DependencyDetectionService.php | 115 ----------- src/Service/DiffParser/ComposerDiffParser.php | 174 ----------------- .../DiffParser/DiffParserInterface.php | 29 --- src/Service/DiffParser/DiffParserRegistry.php | 81 -------- src/Service/DiffParser/PackageDiffParser.php | 180 ------------------ src/Service/FeedFetcher.php | 99 ---------- src/Service/FeedFetcherInterface.php | 42 ---- .../DependenciesChangesControllerTest.php | 8 +- .../Controller/TimelineControllerTest.php | 4 +- .../SpiriitCommitHistoryExtensionTest.php | 4 +- tests/Mock/ArrayCacheAdapter.php | 55 ++++++ tests/Unit/Command/ClearCacheCommandTest.php | 4 +- .../Unit/Command/RefreshCacheCommandTest.php | 4 +- tests/Unit/DTO/CommitTest.php | 2 +- tests/Unit/DTO/DependencyChangeTest.php | 2 +- .../Unit/Provider/Github/CommitParserTest.php | 4 +- tests/Unit/Provider/Github/ProviderTest.php | 159 ++++++++-------- .../Unit/Provider/Gitlab/CommitParserTest.php | 4 +- tests/Unit/Provider/Gitlab/ProviderTest.php | 141 +++++++------- .../DependencyDetectionServiceTest.php | 12 +- .../DiffParser/ComposerDiffParserTest.php | 4 +- .../DiffParser/DiffParserRegistryTest.php | 8 +- .../DiffParser/PackageDiffParserTest.php | 4 +- tests/Unit/Service/FeedFetcherTest.php | 30 +-- 41 files changed, 401 insertions(+), 1464 deletions(-) create mode 100644 src/Adapter/SymfonyCacheAdapter.php create mode 100644 src/Adapter/SymfonyHttpClientAdapter.php delete mode 100644 src/DTO/Commit.php delete mode 100644 src/DTO/DependencyChange.php delete mode 100644 src/Provider/CommitParserInterface.php delete mode 100644 src/Provider/Github/CommitParser.php delete mode 100644 src/Provider/Github/Provider.php delete mode 100644 src/Provider/Gitlab/CommitParser.php delete mode 100644 src/Provider/Gitlab/Provider.php delete mode 100644 src/Provider/ProviderInterface.php delete mode 100644 src/Service/DependencyDetectionService.php delete mode 100644 src/Service/DiffParser/ComposerDiffParser.php delete mode 100644 src/Service/DiffParser/DiffParserInterface.php delete mode 100644 src/Service/DiffParser/DiffParserRegistry.php delete mode 100644 src/Service/DiffParser/PackageDiffParser.php delete mode 100644 src/Service/FeedFetcher.php delete mode 100644 src/Service/FeedFetcherInterface.php create mode 100644 tests/Mock/ArrayCacheAdapter.php diff --git a/composer.json b/composer.json index 1958c79..a9a92b0 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,15 @@ "email": "dev@spiriit.com" } ], + "repositories": [ + { + "type": "path", + "url": "../commit-history-lib" + } + ], "require": { "php": ">=8.2", + "spiriitlabs/commit-history": "@dev", "symfony/cache": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", "symfony/framework-bundle": "^6.4|^7.0", diff --git a/src/Adapter/SymfonyCacheAdapter.php b/src/Adapter/SymfonyCacheAdapter.php new file mode 100644 index 0000000..3a47712 --- /dev/null +++ b/src/Adapter/SymfonyCacheAdapter.php @@ -0,0 +1,40 @@ + + * 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\CacheInterface; +use Symfony\Contracts\Cache\CacheInterface as SymfonyCacheInterface; +use Symfony\Contracts\Cache\ItemInterface; + +final class SymfonyCacheAdapter implements CacheInterface +{ + public function __construct( + private readonly SymfonyCacheInterface $cache, + ) { + } + + public function get(string $key, callable $callback, ?int $ttl = null): mixed + { + return $this->cache->get($key, function (ItemInterface $item) use ($callback, $ttl): mixed { + if (null !== $ttl) { + $item->expiresAfter($ttl); + } + + return $callback(); + }); + } + + public function delete(string $key): bool + { + return $this->cache->delete($key); + } +} 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..a644db9 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\CommitHistory\Service\DependencyDetectionService; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; diff --git a/src/Command/RefreshCacheCommand.php b/src/Command/RefreshCacheCommand.php index b9d851b..e4a63d2 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\CommitHistory\Service\DependencyDetectionService; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -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 { 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..cf2aaec 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; 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..b48a68d 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -11,30 +11,49 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Spiriit\Bundle\CommitHistoryBundle\Adapter\SymfonyCacheAdapter; +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\CommitHistory\Contract\CacheInterface; +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\DependencyDetectionService; +use Spiriit\CommitHistory\Service\FeedFetcher; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; return static function (ContainerConfigurator $container): void { $services = $container->services(); + // Adapters (bridge Symfony to library contracts) + $services->set('spiriit_commit_history.cache_adapter', SymfonyCacheAdapter::class) + ->args([ + service('cache.app'), + ]); + + $services->alias(CacheInterface::class, 'spiriit_commit_history.cache_adapter'); + + $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 +66,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 +75,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'); @@ -102,11 +92,32 @@ $services->set('spiriit_commit_history.dependency_detection', DependencyDetectionService::class) ->args([ service('spiriit_commit_history.provider'), - service('cache.app'), + service('spiriit_commit_history.cache_adapter'), param('spiriit_commit_history.dependency_files'), param('spiriit_commit_history.track_dependency_changes'), ]); + // FeedFetcher (caching wrapper) + $services->set('spiriit_commit_history.feed_fetcher', FeedFetcher::class) + ->args([ + service('spiriit_commit_history.provider'), + service('spiriit_commit_history.cache_adapter'), + 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'); + // Dependencies Changes Controller $services->set('spiriit_commit_history.controller.dependencies_changes', DependenciesChangesController::class) ->args([ @@ -118,7 +129,14 @@ ]) ->tag('controller.service_arguments'); - // Clear Cache Command + // Commands + $services->set('spiriit_commit_history.command.refresh_cache', RefreshCacheCommand::class) + ->args([ + service(FeedFetcherInterface::class), + service('cache.app'), + ]) + ->tag('console.command'); + $services->set('spiriit_commit_history.command.clear_cache', ClearCacheCommand::class) ->args([ service('cache.app'), 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/src/Service/FeedFetcherInterface.php b/src/Service/FeedFetcherInterface.php deleted file mode 100644 index a00cfaa..0000000 --- a/src/Service/FeedFetcherInterface.php +++ /dev/null @@ -1,42 +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; - -interface FeedFetcherInterface -{ - /** - * @return Commit[] - */ - public function fetch(?int $year = null): array; - - /** - * Force refresh the cache and return commits. - * - * @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. - */ - public function getCacheKey(int $year): string; -} 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/Mock/ArrayCacheAdapter.php b/tests/Mock/ArrayCacheAdapter.php new file mode 100644 index 0000000..354c172 --- /dev/null +++ b/tests/Mock/ArrayCacheAdapter.php @@ -0,0 +1,55 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Mock; + +use Spiriit\CommitHistory\Contract\CacheInterface; + +/** + * Simple in-memory cache adapter for testing. + */ +class ArrayCacheAdapter implements CacheInterface +{ + /** + * @var array + */ + private array $cache = []; + + public function get(string $key, callable $callback, ?int $ttl = null): mixed + { + if (!\array_key_exists($key, $this->cache)) { + $this->cache[$key] = $callback(); + } + + return $this->cache[$key]; + } + + public function delete(string $key): bool + { + if (\array_key_exists($key, $this->cache)) { + unset($this->cache[$key]); + + return true; + } + + return false; + } + + public function clear(): void + { + $this->cache = []; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->cache); + } +} diff --git a/tests/Unit/Command/ClearCacheCommandTest.php b/tests/Unit/Command/ClearCacheCommandTest.php index 569e042..6341140 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\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; diff --git a/tests/Unit/Command/RefreshCacheCommandTest.php b/tests/Unit/Command/RefreshCacheCommandTest.php index 3598bec..2bf5292 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\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; 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..d1a3b87 100644 --- a/tests/Unit/Service/DependencyDetectionServiceTest.php +++ b/tests/Unit/Service/DependencyDetectionServiceTest.php @@ -13,19 +13,19 @@ 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 Symfony\Component\Cache\Adapter\ArrayAdapter; +use Spiriit\Bundle\CommitHistoryBundle\Tests\Mock\ArrayCacheAdapter; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Provider\ProviderInterface; +use Spiriit\CommitHistory\Service\DependencyDetectionService; class DependencyDetectionServiceTest extends TestCase { - private ArrayAdapter $cache; + private ArrayCacheAdapter $cache; private ProviderInterface&MockObject $provider; protected function setUp(): void { - $this->cache = new ArrayAdapter(); + $this->cache = new ArrayCacheAdapter(); $this->provider = $this->createMock(ProviderInterface::class); } 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..db5c5bf 100644 --- a/tests/Unit/Service/FeedFetcherTest.php +++ b/tests/Unit/Service/FeedFetcherTest.php @@ -12,18 +12,18 @@ 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 Symfony\Component\Cache\Adapter\ArrayAdapter; +use Spiriit\Bundle\CommitHistoryBundle\Tests\Mock\ArrayCacheAdapter; +use Spiriit\CommitHistory\DTO\Commit; +use Spiriit\CommitHistory\Provider\ProviderInterface; +use Spiriit\CommitHistory\Service\FeedFetcher; class FeedFetcherTest extends TestCase { - private ArrayAdapter $cache; + private ArrayCacheAdapter $cache; protected function setUp(): void { - $this->cache = new ArrayAdapter(); + $this->cache = new ArrayCacheAdapter(); } public function testFetchReturnsCommits(): void @@ -111,24 +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 = [ From 10c7154057d05d9f854d599e7fdebb3b0fb3929c Mon Sep 17 00:00:00 2001 From: Romain MILLAN Date: Mon, 12 Jan 2026 15:56:01 +0100 Subject: [PATCH 2/3] cache --- README.md | 52 ++++++- src/Adapter/SymfonyCacheAdapter.php | 40 ------ src/Command/ClearCacheCommand.php | 8 +- src/Command/RefreshCacheCommand.php | 8 +- .../SpiriitCommitHistoryExtension.php | 19 +++ src/Resources/config/services.php | 43 +++--- .../CachingDependencyDetectionService.php | 66 +++++++++ src/Service/CachingFeedFetcher.php | 80 +++++++++++ src/Service/CachingFeedFetcherInterface.php | 35 +++++ tests/Mock/ArrayCacheAdapter.php | 55 ------- tests/Unit/Command/ClearCacheCommandTest.php | 6 +- .../Unit/Command/RefreshCacheCommandTest.php | 6 +- .../DependencyDetectionServiceTest.php | 74 +++------- tests/Unit/Service/FeedFetcherTest.php | 135 +++++++----------- 14 files changed, 361 insertions(+), 266 deletions(-) delete mode 100644 src/Adapter/SymfonyCacheAdapter.php create mode 100644 src/Service/CachingDependencyDetectionService.php create mode 100644 src/Service/CachingFeedFetcher.php create mode 100644 src/Service/CachingFeedFetcherInterface.php delete mode 100644 tests/Mock/ArrayCacheAdapter.php 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 - * 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\CacheInterface; -use Symfony\Contracts\Cache\CacheInterface as SymfonyCacheInterface; -use Symfony\Contracts\Cache\ItemInterface; - -final class SymfonyCacheAdapter implements CacheInterface -{ - public function __construct( - private readonly SymfonyCacheInterface $cache, - ) { - } - - public function get(string $key, callable $callback, ?int $ttl = null): mixed - { - return $this->cache->get($key, function (ItemInterface $item) use ($callback, $ttl): mixed { - if (null !== $ttl) { - $item->expiresAfter($ttl); - } - - return $callback(); - }); - } - - public function delete(string $key): bool - { - return $this->cache->delete($key); - } -} diff --git a/src/Command/ClearCacheCommand.php b/src/Command/ClearCacheCommand.php index a644db9..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\CommitHistory\Service\DependencyDetectionService; -use Spiriit\CommitHistory\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 e4a63d2..6a94c15 100644 --- a/src/Command/RefreshCacheCommand.php +++ b/src/Command/RefreshCacheCommand.php @@ -11,8 +11,8 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Command; -use Spiriit\CommitHistory\Service\DependencyDetectionService; -use Spiriit\CommitHistory\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(); @@ -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/DependencyInjection/SpiriitCommitHistoryExtension.php b/src/DependencyInjection/SpiriitCommitHistoryExtension.php index cf2aaec..8951015 100644 --- a/src/DependencyInjection/SpiriitCommitHistoryExtension.php +++ b/src/DependencyInjection/SpiriitCommitHistoryExtension.php @@ -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/Resources/config/services.php b/src/Resources/config/services.php index b48a68d..0da62de 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -11,13 +11,14 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use Spiriit\Bundle\CommitHistoryBundle\Adapter\SymfonyCacheAdapter; 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\CommitHistory\Contract\CacheInterface; +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; @@ -26,21 +27,13 @@ use Spiriit\CommitHistory\Provider\Github\GithubProvider; use Spiriit\CommitHistory\Provider\Gitlab\CommitParser as GitlabCommitParser; use Spiriit\CommitHistory\Provider\Gitlab\GitlabProvider; -use Spiriit\CommitHistory\Service\DependencyDetectionService; use Spiriit\CommitHistory\Service\FeedFetcher; use Spiriit\CommitHistory\Service\FeedFetcherInterface; return static function (ContainerConfigurator $container): void { $services = $container->services(); - // Adapters (bridge Symfony to library contracts) - $services->set('spiriit_commit_history.cache_adapter', SymfonyCacheAdapter::class) - ->args([ - service('cache.app'), - ]); - - $services->alias(CacheInterface::class, 'spiriit_commit_history.cache_adapter'); - + // HTTP Client Adapter (bridges Symfony to library contract) $services->set('spiriit_commit_history.http_client_adapter', SymfonyHttpClientAdapter::class) ->args([ service('http_client'), @@ -88,26 +81,34 @@ 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('spiriit_commit_history.cache_adapter'), param('spiriit_commit_history.dependency_files'), param('spiriit_commit_history.track_dependency_changes'), + service('cache.app'), ]); - // FeedFetcher (caching wrapper) - $services->set('spiriit_commit_history.feed_fetcher', FeedFetcher::class) + // 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'), - service('spiriit_commit_history.cache_adapter'), - param('spiriit_commit_history.cache_ttl'), 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'), + ]); + $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) @@ -118,7 +119,7 @@ ]) ->tag('controller.service_arguments'); - // Dependencies Changes Controller + // 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'), @@ -132,7 +133,7 @@ // Commands $services->set('spiriit_commit_history.command.refresh_cache', RefreshCacheCommand::class) ->args([ - service(FeedFetcherInterface::class), + service(CachingFeedFetcherInterface::class), service('cache.app'), ]) ->tag('console.command'); @@ -140,7 +141,7 @@ $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/CachingFeedFetcherInterface.php b/src/Service/CachingFeedFetcherInterface.php new file mode 100644 index 0000000..79a68c0 --- /dev/null +++ b/src/Service/CachingFeedFetcherInterface.php @@ -0,0 +1,35 @@ + + * 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; + +/** + * Extended interface for FeedFetcher with caching capabilities. + */ +interface CachingFeedFetcherInterface extends FeedFetcherInterface +{ + /** + * 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 the cache key for a specific year. + * Useful for cache clearing commands. + */ + public function getCacheKey(int $year): string; +} diff --git a/tests/Mock/ArrayCacheAdapter.php b/tests/Mock/ArrayCacheAdapter.php deleted file mode 100644 index 354c172..0000000 --- a/tests/Mock/ArrayCacheAdapter.php +++ /dev/null @@ -1,55 +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\Tests\Mock; - -use Spiriit\CommitHistory\Contract\CacheInterface; - -/** - * Simple in-memory cache adapter for testing. - */ -class ArrayCacheAdapter implements CacheInterface -{ - /** - * @var array - */ - private array $cache = []; - - public function get(string $key, callable $callback, ?int $ttl = null): mixed - { - if (!\array_key_exists($key, $this->cache)) { - $this->cache[$key] = $callback(); - } - - return $this->cache[$key]; - } - - public function delete(string $key): bool - { - if (\array_key_exists($key, $this->cache)) { - unset($this->cache[$key]); - - return true; - } - - return false; - } - - public function clear(): void - { - $this->cache = []; - } - - public function has(string $key): bool - { - return \array_key_exists($key, $this->cache); - } -} diff --git a/tests/Unit/Command/ClearCacheCommandTest.php b/tests/Unit/Command/ClearCacheCommandTest.php index 6341140..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\Service\CachingFeedFetcherInterface; use Spiriit\CommitHistory\DTO\Commit; -use Spiriit\CommitHistory\Service\FeedFetcherInterface; 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 2bf5292..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\Service\CachingFeedFetcherInterface; use Spiriit\CommitHistory\DTO\Commit; -use Spiriit\CommitHistory\Service\FeedFetcherInterface; 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/Service/DependencyDetectionServiceTest.php b/tests/Unit/Service/DependencyDetectionServiceTest.php index d1a3b87..db7100d 100644 --- a/tests/Unit/Service/DependencyDetectionServiceTest.php +++ b/tests/Unit/Service/DependencyDetectionServiceTest.php @@ -13,19 +13,19 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\Tests\Mock\ArrayCacheAdapter; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingDependencyDetectionService; use Spiriit\CommitHistory\DTO\Commit; use Spiriit\CommitHistory\Provider\ProviderInterface; -use Spiriit\CommitHistory\Service\DependencyDetectionService; +use Symfony\Component\Cache\Adapter\ArrayAdapter; class DependencyDetectionServiceTest extends TestCase { - private ArrayCacheAdapter $cache; + private ArrayAdapter $cache; private ProviderInterface&MockObject $provider; protected function setUp(): void { - $this->cache = new ArrayCacheAdapter(); + $this->cache = new ArrayAdapter(); $this->provider = $this->createMock(ProviderInterface::class); } @@ -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/FeedFetcherTest.php b/tests/Unit/Service/FeedFetcherTest.php index db5c5bf..245aa89 100644 --- a/tests/Unit/Service/FeedFetcherTest.php +++ b/tests/Unit/Service/FeedFetcherTest.php @@ -12,18 +12,18 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Service; use PHPUnit\Framework\TestCase; -use Spiriit\Bundle\CommitHistoryBundle\Tests\Mock\ArrayCacheAdapter; +use Spiriit\Bundle\CommitHistoryBundle\Service\CachingFeedFetcher; use Spiriit\CommitHistory\DTO\Commit; -use Spiriit\CommitHistory\Provider\ProviderInterface; -use Spiriit\CommitHistory\Service\FeedFetcher; +use Spiriit\CommitHistory\Service\FeedFetcherInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; class FeedFetcherTest extends TestCase { - private ArrayCacheAdapter $cache; + private ArrayAdapter $cache; protected function setUp(): void { - $this->cache = new ArrayCacheAdapter(); + $this->cache = new ArrayAdapter(); } public function testFetchReturnsCommits(): void @@ -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,32 +111,6 @@ public function testRefreshInvalidatesCache(): void $this->assertSame('def456', $result2[0]->id); } - 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 = [ @@ -146,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 @@ -198,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); From 4ac21110f9ae4dd97a9ad04679187f2cfa2c54c5 Mon Sep 17 00:00:00 2001 From: Romain MILLAN Date: Wed, 11 Feb 2026 13:09:10 +0100 Subject: [PATCH 3/3] composer fix --- composer.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/composer.json b/composer.json index a9a92b0..9defd2d 100644 --- a/composer.json +++ b/composer.json @@ -10,15 +10,9 @@ "email": "dev@spiriit.com" } ], - "repositories": [ - { - "type": "path", - "url": "../commit-history-lib" - } - ], "require": { "php": ">=8.2", - "spiriitlabs/commit-history": "@dev", + "spiriitlabs/commit-history": "^1.0", "symfony/cache": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", "symfony/framework-bundle": "^6.4|^7.0",