From 8bc0cffa208ef13d4da7c20dacbfc2b753206779 Mon Sep 17 00:00:00 2001 From: Romain MILLAN Date: Tue, 6 Jan 2026 09:14:32 +0100 Subject: [PATCH] feat(dependencies): Add on commit checking if dependencies are modified, if it display it in view --- .gitignore | 1 + README.md | 115 +++++- src/Command/ClearCacheCommand.php | 111 ++++++ src/Command/RefreshCacheCommand.php | 32 +- .../DependenciesChangesController.php | 121 ++++++ src/DTO/Commit.php | 14 + src/DTO/DependencyChange.php | 28 ++ src/DependencyInjection/Configuration.php | 14 + .../SpiriitCommitHistoryExtension.php | 2 + src/Provider/Github/Provider.php | 57 +++ src/Provider/Gitlab/Provider.php | 54 +++ src/Provider/ProviderInterface.php | 14 + src/Resources/config/routes.php | 4 + src/Resources/config/services.php | 49 +++ src/Resources/views/_commits.html.twig | 26 +- src/Resources/views/timeline.html.twig | 362 ++++++++++++++++-- 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 | 12 +- src/Service/FeedFetcherInterface.php | 6 + .../DependenciesChangesControllerTest.php | 283 ++++++++++++++ tests/Unit/Command/ClearCacheCommandTest.php | 137 +++++++ .../Unit/Command/RefreshCacheCommandTest.php | 92 ++++- tests/Unit/DTO/CommitTest.php | 58 +++ tests/Unit/DTO/DependencyChangeTest.php | 71 ++++ tests/Unit/Provider/Github/ProviderTest.php | 138 +++++++ tests/Unit/Provider/Gitlab/ProviderTest.php | 121 ++++++ .../DependencyDetectionServiceTest.php | 261 +++++++++++++ .../DiffParser/ComposerDiffParserTest.php | 224 +++++++++++ .../DiffParser/DiffParserRegistryTest.php | 118 ++++++ .../DiffParser/PackageDiffParserTest.php | 225 +++++++++++ 34 files changed, 3267 insertions(+), 62 deletions(-) create mode 100644 src/Command/ClearCacheCommand.php create mode 100644 src/Controller/DependenciesChangesController.php create mode 100644 src/DTO/DependencyChange.php create mode 100644 src/Service/DependencyDetectionService.php create mode 100644 src/Service/DiffParser/ComposerDiffParser.php create mode 100644 src/Service/DiffParser/DiffParserInterface.php create mode 100644 src/Service/DiffParser/DiffParserRegistry.php create mode 100644 src/Service/DiffParser/PackageDiffParser.php create mode 100644 tests/Functional/Controller/DependenciesChangesControllerTest.php create mode 100644 tests/Unit/Command/ClearCacheCommandTest.php create mode 100644 tests/Unit/DTO/DependencyChangeTest.php create mode 100644 tests/Unit/Service/DependencyDetectionServiceTest.php create mode 100644 tests/Unit/Service/DiffParser/ComposerDiffParserTest.php create mode 100644 tests/Unit/Service/DiffParser/DiffParserRegistryTest.php create mode 100644 tests/Unit/Service/DiffParser/PackageDiffParserTest.php diff --git a/.gitignore b/.gitignore index 2ed66a7..dd70cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ var *.lock CLAUDE.md +.claude diff --git a/README.md b/README.md index c1772db..b640e0b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A Symfony bundle that fetches commit history from GitLab or GitHub repositories - **REST API Integration**: Uses official APIs with pagination to fetch all commits - **Self-Hosted Support**: Works with GitLab self-hosted instances and GitHub Enterprise - **Year Filtering**: Filter commits by year with a dropdown, fetched via API with per-year caching +- **Dependency Tracking**: Automatically detect and display dependency changes (composer.json, package.json, etc.) - **Vertical Timeline UI**: Beautiful, responsive timeline inspired by Symfony releases - **Caching**: 1-hour cache by default to reduce API calls (cached per year) - **Private Repository Support**: Supports authentication tokens for private repositories @@ -88,6 +89,8 @@ spiriit_commit_history: | `feed_name` | string | `Commits` | Display name for the timeline | | `cache_ttl` | integer | `3600` | Cache duration in seconds | | `available_years_count` | integer | `6` | Number of years to show in the year filter dropdown | +| `track_dependency_changes` | boolean | `true` | Enable dependency change detection | +| `dependency_files` | array | `[composer.json, composer.lock, package.json, package-lock.json]` | Files to track for dependency changes | #### GitLab Options @@ -180,7 +183,9 @@ Then in your template: ### Console Commands -Refresh the commit cache manually: +#### Refresh Cache + +Refresh the commit cache and re-detect dependency badges: ```bash # Refresh current year (default) @@ -194,7 +199,102 @@ php bin/console spiriit:commit-history:refresh --all php bin/console spiriit:commit-history:refresh -a ``` -This command clears the cache and fetches fresh commits from the provider. Each year is cached separately. +This command clears the commits list cache and dependency detection cache, then fetches fresh data from the provider. Each year is cached separately. + +#### Clear Cache + +Clear all caches (commits list, dependency detection, and dependency changes): + +```bash +# Clear all caches for all years +php bin/console spiriit:commit-history:clear + +# Clear caches for a specific year only +php bin/console spiriit:commit-history:clear 2024 +``` + +This command clears all cached data without re-fetching. Useful if you want to force a complete refresh on the next page load. + +## Dependency Tracking + +The bundle automatically detects commits that modify dependency files (composer.json, package.json, etc.) and displays a "DEPENDENCIES" badge on these commits. Clicking the badge reveals the list of added, updated, or removed dependencies. + +### How It Works + +1. **Badge Detection**: When commits are loaded, the bundle checks if any dependency files were modified. This check is cached per-commit (forever, since commit content is immutable). + +2. **Lazy Loading**: The dependency details are only fetched when a user clicks the badge, reducing initial page load time. + +3. **Per-Commit Caching**: Both badge detection and dependency details are cached per-commit. Since commit IDs are immutable, this cache never needs to expire. + +### Configuration + +```yaml +spiriit_commit_history: + # Enable or disable dependency tracking entirely (default: true) + track_dependency_changes: true + + # Files to track for dependency changes + dependency_files: + - composer.json + - composer.lock + - package.json + - package-lock.json +``` + +### Extending with Custom Parsers + +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`: + +```php + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Command; + +use Spiriit\Bundle\CommitHistoryBundle\Controller\DependenciesChangesController; +use Spiriit\Bundle\CommitHistoryBundle\Service\DependencyDetectionService; +use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\Cache\CacheInterface; + +#[AsCommand( + name: 'spiriit:commit-history:clear', + description: 'Clear all commit history caches (commits list, dependency detection, and dependency changes)', +)] +class ClearCacheCommand extends Command +{ + public function __construct( + private readonly CacheInterface $cache, + private readonly FeedFetcherInterface $feedFetcher, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('year', InputArgument::OPTIONAL, 'The year to clear cache for') + ->addOption('all', 'a', InputOption::VALUE_NONE, 'Clear cache for all years'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $yearArg = $input->getArgument('year') ?? (new \DateTimeImmutable('now'))->format('Y'); + $clearAll = $input->getOption('all'); + + if ($clearAll) { + return $this->clearAllYears($io); + } + + return $this->clearYear((int) $yearArg, $io); + } + + private function clearYear(int $year, SymfonyStyle $io): int + { + $io->text(\sprintf('Clearing cache for year %d...', $year)); + + $commitCount = $this->doClearYear($year); + + $io->success(\sprintf('Cache cleared for year %d (%d commits).', $year, $commitCount)); + + return Command::SUCCESS; + } + + private function clearAllYears(SymfonyStyle $io): int + { + $years = $this->feedFetcher->getAvailableYears(); + $totalCommits = 0; + + $io->text('Clearing cache for all years...'); + + foreach ($years as $year) { + $totalCommits += $this->doClearYear($year); + $io->text(\sprintf(' Year %d: cleared.', $year)); + } + + $io->success(\sprintf('Cache cleared for %d years (%d total commits).', \count($years), $totalCommits)); + + return Command::SUCCESS; + } + + private function doClearYear(int $year): int + { + $commits = $this->feedFetcher->fetch($year); + + foreach ($commits as $commit) { + $this->clearDependencyCaches($commit->id); + } + + $this->cache->delete($this->feedFetcher->getCacheKey($year)); + + return \count($commits); + } + + private function clearDependencyCaches(string $commitId): void + { + // Clear dependency detection cache (badge) + $hasDepsKey = DependencyDetectionService::getCacheKeyPrefix().$commitId; + $this->cache->delete($hasDepsKey); + + // Clear dependency changes cache (list) + $depsKey = DependenciesChangesController::getCacheKeyPrefix().$commitId; + $this->cache->delete($depsKey); + } +} diff --git a/src/Command/RefreshCacheCommand.php b/src/Command/RefreshCacheCommand.php index 7a430c7..b9d851b 100644 --- a/src/Command/RefreshCacheCommand.php +++ b/src/Command/RefreshCacheCommand.php @@ -11,6 +11,7 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Command; +use Spiriit\Bundle\CommitHistoryBundle\Service\DependencyDetectionService; use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -19,15 +20,17 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\Cache\CacheInterface; #[AsCommand( name: 'spiriit:commit-history:refresh', - description: 'Refresh the commit history cache', + description: 'Refresh the commit history cache and dependency detection', )] class RefreshCacheCommand extends Command { public function __construct( private readonly FeedFetcherInterface $feedFetcher, + private readonly CacheInterface $cache, ) { parent::__construct(); } @@ -51,7 +54,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $totalCommits = 0; foreach ($years as $year) { - $commits = $this->feedFetcher->refresh($year); + $commits = $this->refreshYear($year); $count = \count($commits); $totalCommits += $count; $io->text(\sprintf('Year %d: %d commits fetched.', $year, $count)); @@ -59,13 +62,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success(\sprintf('Cache refreshed for all %d years. %d total commits fetched.', \count($years), $totalCommits)); } else { - $year = null !== $yearArg ? (int) $yearArg : null; - $commits = $this->feedFetcher->refresh($year); - $displayYear = $year ?? (int) date('Y'); + $year = null !== $yearArg ? (int) $yearArg : (int) date('Y'); + $commits = $this->refreshYear($year); - $io->success(\sprintf('Cache refreshed for year %d. %d commits fetched.', $displayYear, \count($commits))); + $io->success(\sprintf('Cache refreshed for year %d. %d commits fetched.', $year, \count($commits))); } return Command::SUCCESS; } + + /** + * @return \Spiriit\Bundle\CommitHistoryBundle\DTO\Commit[] + */ + private function refreshYear(int $year): array + { + // Get existing commits to clear their dependency detection cache + $existingCommits = $this->feedFetcher->fetch($year); + + // Clear dependency detection cache for existing commits + foreach ($existingCommits as $commit) { + $hasDepsKey = DependencyDetectionService::getCacheKeyPrefix().$commit->id; + $this->cache->delete($hasDepsKey); + } + + // Refresh commits (clears commits cache, re-fetches, and re-detects dependencies) + return $this->feedFetcher->refresh($year); + } } diff --git a/src/Controller/DependenciesChangesController.php b/src/Controller/DependenciesChangesController.php new file mode 100644 index 0000000..a49005f --- /dev/null +++ b/src/Controller/DependenciesChangesController.php @@ -0,0 +1,121 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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 Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; + +class DependenciesChangesController +{ + private const CACHE_KEY_PREFIX = 'spiriit_commit_history_deps_'; + + /** + * @param string[] $dependencyFiles + */ + public function __construct( + private readonly ProviderInterface $provider, + private readonly DiffParserRegistry $diffParserRegistry, + private readonly CacheInterface $cache, + private readonly array $dependencyFiles, + private readonly bool $trackDependencyChanges, + ) { + } + + public function __invoke(string $commitId): Response + { + if (!$this->trackDependencyChanges) { + return new JsonResponse( + ['error' => 'Dependency tracking is disabled'], + Response::HTTP_NOT_FOUND + ); + } + + // Validate commit ID format (should be 7-40 hex characters) + if (!preg_match('/^[a-f0-9]{7,40}$/i', $commitId)) { + return new JsonResponse( + ['error' => 'Invalid commit ID'], + Response::HTTP_BAD_REQUEST + ); + } + + try { + $changes = $this->getDependencyChanges($commitId); + } catch (\Throwable $e) { + return new JsonResponse( + ['error' => 'Failed to fetch dependency changes'], + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return new JsonResponse([ + 'commitId' => $commitId, + 'changes' => array_map( + fn (DependencyChange $change) => [ + 'name' => $change->name, + 'type' => $change->type, + 'oldVersion' => $change->oldVersion, + 'newVersion' => $change->newVersion, + 'sourceFile' => $change->sourceFile, + ], + $changes + ), + ]); + } + + /** + * @return DependencyChange[] + */ + private function getDependencyChanges(string $commitId): array + { + $cacheKey = self::CACHE_KEY_PREFIX.$commitId; + + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($commitId): array { + // Cache forever (no TTL) since commit ID is immutable + $item->expiresAfter(null); + + return $this->fetchAndParseDependencyChanges($commitId); + }); + } + + /** + * @return DependencyChange[] + */ + private function fetchAndParseDependencyChanges(string $commitId): array + { + $diffs = $this->provider->getCommitDiff($commitId); + + // Filter to only include dependency files + $dependencyDiffs = []; + foreach ($diffs as $filename => $diff) { + $baseName = basename($filename); + if (\in_array($baseName, $this->dependencyFiles, true)) { + $dependencyDiffs[$filename] = $diff; + } + } + + return $this->diffParserRegistry->parseAll($dependencyDiffs); + } + + /** + * Get the cache key prefix for dependency changes. + * Useful for cache clearing commands. + */ + public static function getCacheKeyPrefix(): string + { + return self::CACHE_KEY_PREFIX; + } +} diff --git a/src/DTO/Commit.php b/src/DTO/Commit.php index 2677124..9bb1403 100644 --- a/src/DTO/Commit.php +++ b/src/DTO/Commit.php @@ -20,6 +20,20 @@ public function __construct( 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 new file mode 100644 index 0000000..d587e16 --- /dev/null +++ b/src/DTO/DependencyChange.php @@ -0,0 +1,28 @@ + + * 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/Configuration.php b/src/DependencyInjection/Configuration.php index 08b0857..5b18208 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -41,6 +41,20 @@ public function getConfigTreeBuilder(): TreeBuilder ->min(1) ->info('Number of years to show in the year filter dropdown') ->end() + ->booleanNode('track_dependency_changes') + ->defaultTrue() + ->info('Enable or disable dependency change tracking') + ->end() + ->arrayNode('dependency_files') + ->scalarPrototype()->end() + ->defaultValue([ + 'composer.json', + 'composer.lock', + 'package.json', + 'package-lock.json', + ]) + ->info('List of dependency files to track for changes') + ->end() ->arrayNode('gitlab') ->children() ->scalarNode('project_id') diff --git a/src/DependencyInjection/SpiriitCommitHistoryExtension.php b/src/DependencyInjection/SpiriitCommitHistoryExtension.php index fa42586..52d719e 100644 --- a/src/DependencyInjection/SpiriitCommitHistoryExtension.php +++ b/src/DependencyInjection/SpiriitCommitHistoryExtension.php @@ -29,6 +29,8 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('spiriit_commit_history.feed_name', $config['feed_name']); $container->setParameter('spiriit_commit_history.cache_ttl', $config['cache_ttl']); $container->setParameter('spiriit_commit_history.available_years_count', $config['available_years_count']); + $container->setParameter('spiriit_commit_history.track_dependency_changes', $config['track_dependency_changes']); + $container->setParameter('spiriit_commit_history.dependency_files', $config['dependency_files']); // Set GitLab parameters $gitlab = $config['gitlab'] ?? []; diff --git a/src/Provider/Github/Provider.php b/src/Provider/Github/Provider.php index e8dea20..57813df 100644 --- a/src/Provider/Github/Provider.php +++ b/src/Provider/Github/Provider.php @@ -87,4 +87,61 @@ private function extractNextUrl(string $linkHeader): ?string 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/Provider.php b/src/Provider/Gitlab/Provider.php index 10c0409..06972ab 100644 --- a/src/Provider/Gitlab/Provider.php +++ b/src/Provider/Gitlab/Provider.php @@ -79,4 +79,58 @@ public function getCommits(?\DateTimeImmutable $since = null, ?\DateTimeImmutabl 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 index ad2d1a0..bede9f2 100644 --- a/src/Provider/ProviderInterface.php +++ b/src/Provider/ProviderInterface.php @@ -19,4 +19,18 @@ 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/routes.php b/src/Resources/config/routes.php index 88ab3de..1a24868 100644 --- a/src/Resources/config/routes.php +++ b/src/Resources/config/routes.php @@ -14,4 +14,8 @@ return static function (RoutingConfigurator $routes): void { $routes->add('spiriit_commit_history_timeline', '/') ->controller('spiriit_commit_history.controller.timeline'); + + $routes->add('spiriit_commit_history_dependencies', '/commits/{commitId}/dependencies') + ->controller('spiriit_commit_history.controller.dependencies_changes') + ->methods(['GET']); }; diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 184c211..dca5d68 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -11,12 +11,18 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +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; @@ -57,6 +63,7 @@ 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'); @@ -74,6 +81,48 @@ $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'); + + $services->set('spiriit_commit_history.diff_parser.package', PackageDiffParser::class) + ->tag('spiriit_commit_history.diff_parser'); + + // Diff Parser Registry + $services->set('spiriit_commit_history.diff_parser_registry', DiffParserRegistry::class) + ->args([ + tagged_iterator('spiriit_commit_history.diff_parser'), + ]); + + // Dependency Detection Service + $services->set('spiriit_commit_history.dependency_detection', DependencyDetectionService::class) + ->args([ + service('spiriit_commit_history.provider'), + service('cache.app'), + param('spiriit_commit_history.dependency_files'), + param('spiriit_commit_history.track_dependency_changes'), + ]); + + // Dependencies Changes Controller + $services->set('spiriit_commit_history.controller.dependencies_changes', DependenciesChangesController::class) + ->args([ + service('spiriit_commit_history.provider'), + service('spiriit_commit_history.diff_parser_registry'), + service('cache.app'), + param('spiriit_commit_history.dependency_files'), + param('spiriit_commit_history.track_dependency_changes'), + ]) + ->tag('controller.service_arguments'); + + // Clear Cache Command + $services->set('spiriit_commit_history.command.clear_cache', ClearCacheCommand::class) + ->args([ + service('cache.app'), + service(FeedFetcherInterface::class), ]) ->tag('console.command'); }; diff --git a/src/Resources/views/_commits.html.twig b/src/Resources/views/_commits.html.twig index 4c657d6..cfca8c6 100644 --- a/src/Resources/views/_commits.html.twig +++ b/src/Resources/views/_commits.html.twig @@ -1,4 +1,10 @@ -{# Use this to embed in your own layout: {{ include('@SpiriitCommitHistory/_commits.html.twig', { commits: commits }) }} #} +{# + Use this to embed in your own layout: + {{ include('@SpiriitCommitHistory/_commits.html.twig', { commits: commits }) }} + + Note: When using the dependencies accordion feature, make sure to include the CSS styles + and JavaScript from timeline.html.twig in your layout. +#} {% if commits is empty %}
@@ -25,9 +31,21 @@
{% for commit in day_commits %} -
-
{{ commit.title }}
-
+ {% if commit.hasDependenciesChanges %} +
+ + {{ commit.title }} + Dependencies + +
+

Loading...

+
+
+ {% else %} +
+
{{ commit.title }}
+
+ {% endif %} {% endfor %}
diff --git a/src/Resources/views/timeline.html.twig b/src/Resources/views/timeline.html.twig index 4c77b87..5b0989a 100644 --- a/src/Resources/views/timeline.html.twig +++ b/src/Resources/views/timeline.html.twig @@ -30,11 +30,95 @@ margin: 0 auto; } - h1 { + /* Header */ + .header { + margin-bottom: 5rem; + } + + .header__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + } + + .header__title { font-size: 1.875rem; font-weight: 700; - margin-bottom: 2rem; color: #111827; + margin: 0; + } + + .header__filter-select { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + background: #fff; + color: #1f2937; + cursor: pointer; + } + + .header__filter-select:focus { + outline: none; + border-color: var(--timeline-dot-color); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); + } + + .header__description { + color: #6b7280; + font-size: 0.875rem; + line-height: 1.7; + margin: 0; + } + + .header__legend { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e5e7eb; + } + + .header__legend-item { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + color: #4b5563; + } + + /* Shared icon styles */ + .dep-icon { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + font-weight: 700; + } + + .dep-icon--added { + background: #dcfce7; + color: #16a34a; + } + + .dep-icon--updated { + background: #dbeafe; + color: #2563eb; + } + + .dep-icon--removed { + background: #fee2e2; + color: #dc2626; + } + + .header__legend-icon { + width: 1.125rem; + height: 1.125rem; + border-radius: 0.1875rem; + font-size: 0.75rem; } /* Block */ @@ -114,30 +198,118 @@ padding-top: 0; } + /* Details item (commit with dependencies) */ + details.timeline__item { + cursor: default; + } + + .timeline__summary { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + list-style: none; + } + + .timeline__summary::-webkit-details-marker { + display: none; + } + + .timeline__summary::marker { + display: none; + } + .timeline__title { font-weight: 500; color: #1f2937; word-break: break-word; + flex: 1; } - .timeline__filter { - margin-bottom: 1.5rem; + /* Dependencies badge */ + .timeline__badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + background: #dbeafe; + color: #1d4ed8; + border-radius: 0.25rem; + transition: background-color 0.15s ease; + flex-shrink: 0; } - .timeline__filter-select { - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid #d1d5db; + .timeline__badge::after { + content: ' ▸'; + font-size: 0.625rem; + margin-left: 0.25rem; + } + + details.timeline__item[open] .timeline__badge::after { + content: ' ▾'; + } + + .timeline__summary:hover .timeline__badge { + background: #bfdbfe; + } + + .timeline__item--loading .timeline__badge { + opacity: 0.6; + } + + /* Dependencies content */ + .timeline__dependencies { + margin-top: 0.75rem; + padding: 0.75rem; + background: #f9fafb; border-radius: 0.375rem; - background: #fff; - color: #1f2937; - cursor: pointer; + border: 1px solid #e5e7eb; } - .timeline__filter-select:focus { - outline: none; - border-color: var(--timeline-dot-color); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); + .timeline__dependencies-list { + list-style: none; + margin: 0; + padding: 0; + } + + .timeline__dependencies-item { + display: flex; + align-items: center; + padding: 0.375rem 0; + font-size: 0.875rem; + border-bottom: 1px solid #f3f4f6; + } + + .timeline__dependencies-item:last-child { + border-bottom: none; + } + + .timeline__dependencies-name { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 500; + flex: 1; + } + + .timeline__dependencies-version { + color: #6b7280; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + } + + .timeline__dependencies-icon { + width: 1.25rem; + height: 1.25rem; + margin-right: 0.625rem; + flex-shrink: 0; + font-size: 0.875rem; + } + + .timeline__dependencies-empty { + color: #6b7280; + font-style: italic; + margin: 0; } @media (max-width: 640px) { @@ -145,6 +317,17 @@ padding: 1rem; } + .header__top { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .header__legend { + flex-direction: column; + gap: 0.5rem; + } + .timeline { padding-left: 2rem; } @@ -157,19 +340,40 @@
-

{{ feed_name }}

- - {% if available_years is defined and available_years|length > 0 %} -
- -
- {% endif %} +
+
+

{{ feed_name }}

+ {% if available_years is defined and available_years|length > 0 %} +
+ +
+ {% endif %} +
+

+ This timeline shows the history of updates and changes. + Click on entries marked with Dependencies to see which packages were modified. +

+
+ + + + New package + + + + Updated version + + + + Removed package + +
+
{% if commits is empty %}
@@ -196,9 +400,21 @@
{% for commit in day_commits %} -
-
{{ commit.title }}
-
+ {% if commit.hasDependenciesChanges %} +
+ + {{ commit.title }} + Dependencies + +
+

Loading...

+
+
+ {% else %} +
+
{{ commit.title }}
+
+ {% endif %} {% endfor %}
@@ -206,5 +422,89 @@ {% endif %} + + diff --git a/src/Service/DependencyDetectionService.php b/src/Service/DependencyDetectionService.php new file mode 100644 index 0000000..ca7964d --- /dev/null +++ b/src/Service/DependencyDetectionService.php @@ -0,0 +1,115 @@ + + * 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 new file mode 100644 index 0000000..3332c7d --- /dev/null +++ b/src/Service/DiffParser/ComposerDiffParser.php @@ -0,0 +1,174 @@ + + * 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 new file mode 100644 index 0000000..200bbcf --- /dev/null +++ b/src/Service/DiffParser/DiffParserInterface.php @@ -0,0 +1,29 @@ + + * 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 new file mode 100644 index 0000000..b8a1470 --- /dev/null +++ b/src/Service/DiffParser/DiffParserRegistry.php @@ -0,0 +1,81 @@ + + * 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 new file mode 100644 index 0000000..667fc4b --- /dev/null +++ b/src/Service/DiffParser/PackageDiffParser.php @@ -0,0 +1,180 @@ + + * 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 index 2e4811d..bbf42f0 100644 --- a/src/Service/FeedFetcher.php +++ b/src/Service/FeedFetcher.php @@ -23,6 +23,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly int $cacheTtl = 3600, private readonly int $availableYearsCount = 6, + private readonly ?DependencyDetectionService $dependencyDetectionService = null, ) { } @@ -34,7 +35,7 @@ public function fetch(?int $year = null): array $year = $year ?? (int) date('Y'); [$since, $until] = $this->getYearDateRange($year); - return $this->cache->get($this->getCacheKey($year), function (ItemInterface $item) use ($since, $until): array { + $commits = $this->cache->get($this->getCacheKey($year), function (ItemInterface $item) use ($since, $until): array { $commits = $this->provider->getCommits($since, $until); if (empty($commits)) { @@ -45,6 +46,13 @@ public function fetch(?int $year = null): array return $commits; }); + + // Detect dependency changes for each commit (uses per-commit caching) + if (null !== $this->dependencyDetectionService) { + $commits = $this->dependencyDetectionService->detectForCommits($commits); + } + + return $commits; } /** @@ -73,7 +81,7 @@ public function getAvailableYears(): array return $years; } - private function getCacheKey(int $year): string + public function getCacheKey(int $year): string { return 'spiriit_commit_history_feed_'.md5(\get_class($this->provider)).'_'.$year; } diff --git a/src/Service/FeedFetcherInterface.php b/src/Service/FeedFetcherInterface.php index 819c5db..a00cfaa 100644 --- a/src/Service/FeedFetcherInterface.php +++ b/src/Service/FeedFetcherInterface.php @@ -33,4 +33,10 @@ public function refresh(?int $year = null): array; * @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 new file mode 100644 index 0000000..2dbbdb2 --- /dev/null +++ b/tests/Functional/Controller/DependenciesChangesControllerTest.php @@ -0,0 +1,283 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Functional\Controller; + +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 Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; + +class DependenciesChangesControllerTest extends TestCase +{ + private ArrayAdapter $cache; + private ProviderInterface&MockObject $provider; + private DiffParserRegistry $registry; + + protected function setUp(): void + { + $this->cache = new ArrayAdapter(); + $this->provider = $this->createMock(ProviderInterface::class); + $this->registry = new DiffParserRegistry([ + new ComposerDiffParser(), + new PackageDiffParser(), + ]); + } + + public function testInvokeReturnsJsonResponse(): void + { + $diff = <<<'DIFF' ++ "symfony/http-client": "^7.0", +- "old/package": "^1.0", +DIFF; + + $this->provider + ->expects($this->once()) + ->method('getCommitDiff') + ->with('abc123def456789012345678901234567890abcd') + ->willReturn(['composer.json' => $diff]); + + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json', 'composer.lock', 'package.json', 'package-lock.json'], + true, + ); + + $response = $controller('abc123def456789012345678901234567890abcd'); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertSame('abc123def456789012345678901234567890abcd', $data['commitId']); + $this->assertCount(2, $data['changes']); + } + + public function testInvokeWithShortCommitId(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitDiff') + ->with('abc123d') + ->willReturn(['composer.json' => '+ "symfony/http-client": "^7.0",']); + + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json'], + true, + ); + + $response = $controller('abc123d'); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testInvokeReturnsNotFoundWhenTrackingDisabled(): void + { + $this->provider + ->expects($this->never()) + ->method('getCommitDiff'); + + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json'], + false, + ); + + $response = $controller('abc123d'); + + $this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertSame('Dependency tracking is disabled', $data['error']); + } + + public function testInvokeReturnsBadRequestForInvalidCommitId(): void + { + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json'], + true, + ); + + // Too short + $response = $controller('abc'); + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + + // Invalid characters + $response = $controller('abc123g'); + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + + // Contains non-hex characters + $response = $controller('xyz1234'); + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + } + + public function testInvokeReturnsServerErrorOnProviderFailure(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitDiff') + ->willThrowException(new \RuntimeException('API error')); + + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json'], + true, + ); + + $response = $controller('abc123d'); + + $this->assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertSame('Failed to fetch dependency changes', $data['error']); + } + + public function testInvokeCachesResult(): void + { + $diff = '+ "symfony/http-client": "^7.0",'; + + $this->provider + ->expects($this->once()) + ->method('getCommitDiff') + ->with('abc123d') + ->willReturn(['composer.json' => $diff]); + + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json'], + true, + ); + + // First call - fetches from provider + $response1 = $controller('abc123d'); + + // Second call - should use cache + $response2 = $controller('abc123d'); + + $this->assertSame($response1->getContent(), $response2->getContent()); + } + + public function testInvokeFiltersNonDependencyFiles(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitDiff') + ->willReturn([ + 'composer.json' => '+ "symfony/http-client": "^7.0",', + 'README.md' => '+ Some text', + 'src/Controller.php' => '+ class Foo {}', + ]); + + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json', 'package.json'], + true, + ); + + $response = $controller('abc123d'); + + $data = json_decode($response->getContent(), true); + $this->assertCount(1, $data['changes']); + $this->assertSame('symfony/http-client', $data['changes'][0]['name']); + } + + public function testInvokeReturnsEmptyChangesWhenNoDependencyFiles(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitDiff') + ->willReturn(['README.md' => '+ Some text']); + + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json'], + true, + ); + + $response = $controller('abc123d'); + + $data = json_decode($response->getContent(), true); + $this->assertCount(0, $data['changes']); + } + + public function testInvokeResponseStructure(): void + { + $diff = <<<'DIFF' +- "symfony/http-client": "^6.4", ++ "symfony/http-client": "^7.0", +DIFF; + + $this->provider + ->expects($this->once()) + ->method('getCommitDiff') + ->willReturn(['path/to/composer.json' => $diff]); + + $controller = new DependenciesChangesController( + $this->provider, + $this->registry, + $this->cache, + ['composer.json'], + true, + ); + + $response = $controller('abc123d'); + + $data = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('commitId', $data); + $this->assertArrayHasKey('changes', $data); + $this->assertCount(1, $data['changes']); + + $change = $data['changes'][0]; + $this->assertArrayHasKey('name', $change); + $this->assertArrayHasKey('type', $change); + $this->assertArrayHasKey('oldVersion', $change); + $this->assertArrayHasKey('newVersion', $change); + $this->assertArrayHasKey('sourceFile', $change); + + $this->assertSame('symfony/http-client', $change['name']); + $this->assertSame('updated', $change['type']); + $this->assertSame('^6.4', $change['oldVersion']); + $this->assertSame('^7.0', $change['newVersion']); + $this->assertSame('path/to/composer.json', $change['sourceFile']); + } + + public function testGetCacheKeyPrefix(): void + { + $prefix = DependenciesChangesController::getCacheKeyPrefix(); + + $this->assertSame('spiriit_commit_history_deps_', $prefix); + } +} diff --git a/tests/Unit/Command/ClearCacheCommandTest.php b/tests/Unit/Command/ClearCacheCommandTest.php new file mode 100644 index 0000000..569e042 --- /dev/null +++ b/tests/Unit/Command/ClearCacheCommandTest.php @@ -0,0 +1,137 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Command; + +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 Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; + +class ClearCacheCommandTest extends TestCase +{ + private ArrayAdapter $cache; + private FeedFetcherInterface&MockObject $feedFetcher; + + protected function setUp(): void + { + $this->cache = new ArrayAdapter(); + $this->feedFetcher = $this->createMock(FeedFetcherInterface::class); + } + + public function testExecuteClearsAllYearsWithAllOption(): void + { + $commits = [ + new Commit('abc123', 'Commit 1', new \DateTimeImmutable(), 'Author', 'https://example.com'), + new Commit('def456', 'Commit 2', new \DateTimeImmutable(), 'Author', 'https://example.com'), + ]; + + $currentYear = (int) date('Y'); + $availableYears = [$currentYear, $currentYear - 1]; + + $this->feedFetcher + ->expects($this->once()) + ->method('getAvailableYears') + ->willReturn($availableYears); + + $this->feedFetcher + ->expects($this->exactly(2)) + ->method('fetch') + ->willReturn($commits); + + $this->feedFetcher + ->expects($this->exactly(2)) + ->method('getCacheKey') + ->willReturnCallback(fn (int $year) => 'cache_key_'.$year); + + $command = new ClearCacheCommand($this->cache, $this->feedFetcher); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute(['--all' => true]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Cache cleared for 2 years (4 total commits)', $commandTester->getDisplay()); + } + + public function testExecuteClearsCurrentYearByDefault(): void + { + $commits = [ + new Commit('abc123', 'Commit 1', new \DateTimeImmutable(), 'Author', 'https://example.com'), + ]; + + $currentYear = (int) date('Y'); + + $this->feedFetcher + ->expects($this->once()) + ->method('fetch') + ->with($currentYear) + ->willReturn($commits); + + $this->feedFetcher + ->expects($this->once()) + ->method('getCacheKey') + ->with($currentYear) + ->willReturn('cache_key_'.$currentYear); + + $command = new ClearCacheCommand($this->cache, $this->feedFetcher); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString(\sprintf('Cache cleared for year %d (1 commits)', $currentYear), $commandTester->getDisplay()); + } + + public function testExecuteClearsSpecificYear(): void + { + $commits = [ + new Commit('abc123', 'Commit 1', new \DateTimeImmutable(), 'Author', 'https://example.com'), + ]; + + $this->feedFetcher + ->expects($this->once()) + ->method('fetch') + ->with(2024) + ->willReturn($commits); + + $this->feedFetcher + ->expects($this->once()) + ->method('getCacheKey') + ->with(2024) + ->willReturn('cache_key_2024'); + + $command = new ClearCacheCommand($this->cache, $this->feedFetcher); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute(['year' => '2024']); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Cache cleared for year 2024 (1 commits)', $commandTester->getDisplay()); + } + + public function testCommandName(): void + { + $command = new ClearCacheCommand($this->cache, $this->feedFetcher); + + $this->assertSame('spiriit:commit-history:clear', $command->getName()); + } + + public function testCommandDescription(): void + { + $command = new ClearCacheCommand($this->cache, $this->feedFetcher); + + $this->assertStringContainsString('Clear all commit history caches', $command->getDescription()); + } +} diff --git a/tests/Unit/Command/RefreshCacheCommandTest.php b/tests/Unit/Command/RefreshCacheCommandTest.php index db1cfa8..3598bec 100644 --- a/tests/Unit/Command/RefreshCacheCommandTest.php +++ b/tests/Unit/Command/RefreshCacheCommandTest.php @@ -11,29 +11,45 @@ namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Command; +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 Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; class RefreshCacheCommandTest extends TestCase { + private ArrayAdapter $cache; + private FeedFetcherInterface&MockObject $feedFetcher; + + protected function setUp(): void + { + $this->cache = new ArrayAdapter(); + $this->feedFetcher = $this->createMock(FeedFetcherInterface::class); + } + public function testExecuteRefreshesCurrentYearByDefault(): void { + $currentYear = (int) date('Y'); $commits = [ new Commit('abc123', 'Test commit 1', new \DateTimeImmutable(), 'Author', 'https://example.com'), new Commit('def456', 'Test commit 2', new \DateTimeImmutable(), 'Author', 'https://example.com'), ]; - $feedFetcher = $this->createMock(FeedFetcherInterface::class); - $feedFetcher->expects($this->once()) + $this->feedFetcher->expects($this->once()) + ->method('fetch') + ->with($currentYear) + ->willReturn($commits); + + $this->feedFetcher->expects($this->once()) ->method('refresh') - ->with(null) + ->with($currentYear) ->willReturn($commits); - $command = new RefreshCacheCommand($feedFetcher); + $command = new RefreshCacheCommand($this->feedFetcher, $this->cache); $commandTester = new CommandTester($command); $exitCode = $commandTester->execute([]); @@ -48,13 +64,17 @@ public function testExecuteRefreshesSpecificYear(): void new Commit('abc123', 'Test commit', new \DateTimeImmutable(), 'Author', 'https://example.com'), ]; - $feedFetcher = $this->createMock(FeedFetcherInterface::class); - $feedFetcher->expects($this->once()) + $this->feedFetcher->expects($this->once()) + ->method('fetch') + ->with(2024) + ->willReturn($commits); + + $this->feedFetcher->expects($this->once()) ->method('refresh') ->with(2024) ->willReturn($commits); - $command = new RefreshCacheCommand($feedFetcher); + $command = new RefreshCacheCommand($this->feedFetcher, $this->cache); $commandTester = new CommandTester($command); $exitCode = $commandTester->execute(['year' => '2024']); @@ -73,15 +93,19 @@ public function testExecuteRefreshesAllYears(): void $currentYear = (int) date('Y'); $availableYears = [$currentYear, $currentYear - 1, $currentYear - 2]; - $feedFetcher = $this->createMock(FeedFetcherInterface::class); - $feedFetcher->expects($this->once()) + $this->feedFetcher->expects($this->once()) ->method('getAvailableYears') ->willReturn($availableYears); - $feedFetcher->expects($this->exactly(3)) + + $this->feedFetcher->expects($this->exactly(3)) + ->method('fetch') + ->willReturn($commits); + + $this->feedFetcher->expects($this->exactly(3)) ->method('refresh') ->willReturn($commits); - $command = new RefreshCacheCommand($feedFetcher); + $command = new RefreshCacheCommand($this->feedFetcher, $this->cache); $commandTester = new CommandTester($command); $exitCode = $commandTester->execute(['--all' => true]); @@ -98,15 +122,19 @@ public function testExecuteRefreshesAllYearsWithShortOption(): void $currentYear = (int) date('Y'); $availableYears = [$currentYear, $currentYear - 1]; - $feedFetcher = $this->createMock(FeedFetcherInterface::class); - $feedFetcher->expects($this->once()) + $this->feedFetcher->expects($this->once()) ->method('getAvailableYears') ->willReturn($availableYears); - $feedFetcher->expects($this->exactly(2)) + + $this->feedFetcher->expects($this->exactly(2)) + ->method('fetch') + ->willReturn($commits); + + $this->feedFetcher->expects($this->exactly(2)) ->method('refresh') ->willReturn($commits); - $command = new RefreshCacheCommand($feedFetcher); + $command = new RefreshCacheCommand($this->feedFetcher, $this->cache); $commandTester = new CommandTester($command); $exitCode = $commandTester->execute(['-a' => true]); @@ -117,9 +145,39 @@ public function testExecuteRefreshesAllYearsWithShortOption(): void public function testCommandName(): void { - $feedFetcher = $this->createMock(FeedFetcherInterface::class); - $command = new RefreshCacheCommand($feedFetcher); + $command = new RefreshCacheCommand($this->feedFetcher, $this->cache); $this->assertSame('spiriit:commit-history:refresh', $command->getName()); } + + public function testClearsDependencyDetectionCacheBeforeRefresh(): void + { + $commits = [ + new Commit('abc123', 'Test commit', new \DateTimeImmutable(), 'Author', 'https://example.com'), + ]; + + // Pre-populate cache with dependency detection value + $cacheKey = 'spiriit_commit_history_has_deps_abc123'; + $this->cache->get($cacheKey, fn () => true); + + $this->feedFetcher->expects($this->once()) + ->method('fetch') + ->with(2024) + ->willReturn($commits); + + $this->feedFetcher->expects($this->once()) + ->method('refresh') + ->with(2024) + ->willReturn($commits); + + $command = new RefreshCacheCommand($this->feedFetcher, $this->cache); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute(['year' => '2024']); + + $this->assertSame(Command::SUCCESS, $exitCode); + + // Verify cache was cleared (hasItem should return false after delete) + $this->assertFalse($this->cache->hasItem($cacheKey)); + } } diff --git a/tests/Unit/DTO/CommitTest.php b/tests/Unit/DTO/CommitTest.php index 2bd139c..19a2e4f 100644 --- a/tests/Unit/DTO/CommitTest.php +++ b/tests/Unit/DTO/CommitTest.php @@ -49,4 +49,62 @@ public function testCommitWithNullEmail(): void $this->assertNull($commit->authorEmail); } + + public function testCommitHasDependenciesChangesDefaultsToFalse(): void + { + $commit = new Commit( + id: '9668d5f4', + title: 'fix(return): Send slack message', + date: new \DateTimeImmutable(), + author: 'Romain MILLAN', + url: 'https://gitlab.example.com/-/commit/9668d5f4', + ); + + $this->assertFalse($commit->hasDependenciesChanges); + } + + public function testCommitWithHasDependenciesChanges(): void + { + $commit = new Commit( + id: '9668d5f4', + title: 'fix(return): Send slack message', + date: new \DateTimeImmutable(), + author: 'Romain MILLAN', + url: 'https://gitlab.example.com/-/commit/9668d5f4', + hasDependenciesChanges: true, + ); + + $this->assertTrue($commit->hasDependenciesChanges); + } + + public function testWithHasDependenciesChangesReturnsNewInstance(): void + { + $date = new \DateTimeImmutable('2025-12-18T07:19:04+01:00'); + + $commit = new Commit( + id: '9668d5f4', + title: 'fix(return): Send slack message', + date: $date, + author: 'Romain MILLAN', + url: 'https://gitlab.example.com/-/commit/9668d5f4', + authorEmail: 'rmillan@spiriit.com', + hasDependenciesChanges: false, + ); + + $newCommit = $commit->withHasDependenciesChanges(true); + + // Original should be unchanged + $this->assertFalse($commit->hasDependenciesChanges); + + // New instance should have updated value + $this->assertTrue($newCommit->hasDependenciesChanges); + + // Other properties should be preserved + $this->assertSame($commit->id, $newCommit->id); + $this->assertSame($commit->title, $newCommit->title); + $this->assertSame($commit->date, $newCommit->date); + $this->assertSame($commit->author, $newCommit->author); + $this->assertSame($commit->url, $newCommit->url); + $this->assertSame($commit->authorEmail, $newCommit->authorEmail); + } } diff --git a/tests/Unit/DTO/DependencyChangeTest.php b/tests/Unit/DTO/DependencyChangeTest.php new file mode 100644 index 0000000..e2fbd6f --- /dev/null +++ b/tests/Unit/DTO/DependencyChangeTest.php @@ -0,0 +1,71 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\DTO; + +use PHPUnit\Framework\TestCase; +use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange; + +class DependencyChangeTest extends TestCase +{ + public function testDependencyChangeCreation(): void + { + $change = new DependencyChange( + name: 'symfony/http-client', + type: DependencyChange::TYPE_ADDED, + oldVersion: null, + newVersion: '^7.0', + sourceFile: 'composer.json', + ); + + $this->assertSame('symfony/http-client', $change->name); + $this->assertSame(DependencyChange::TYPE_ADDED, $change->type); + $this->assertNull($change->oldVersion); + $this->assertSame('^7.0', $change->newVersion); + $this->assertSame('composer.json', $change->sourceFile); + } + + public function testDependencyChangeWithUpdate(): void + { + $change = new DependencyChange( + name: 'symfony/http-client', + type: DependencyChange::TYPE_UPDATED, + oldVersion: '^6.4', + newVersion: '^7.0', + sourceFile: 'composer.json', + ); + + $this->assertSame(DependencyChange::TYPE_UPDATED, $change->type); + $this->assertSame('^6.4', $change->oldVersion); + $this->assertSame('^7.0', $change->newVersion); + } + + public function testDependencyChangeWithRemoved(): void + { + $change = new DependencyChange( + name: 'old/package', + type: DependencyChange::TYPE_REMOVED, + oldVersion: '^1.0', + ); + + $this->assertSame(DependencyChange::TYPE_REMOVED, $change->type); + $this->assertSame('^1.0', $change->oldVersion); + $this->assertNull($change->newVersion); + $this->assertNull($change->sourceFile); + } + + public function testTypeConstants(): void + { + $this->assertSame('added', DependencyChange::TYPE_ADDED); + $this->assertSame('updated', DependencyChange::TYPE_UPDATED); + $this->assertSame('removed', DependencyChange::TYPE_REMOVED); + } +} diff --git a/tests/Unit/Provider/Github/ProviderTest.php b/tests/Unit/Provider/Github/ProviderTest.php index 11a8cbf..ed0e8aa 100644 --- a/tests/Unit/Provider/Github/ProviderTest.php +++ b/tests/Unit/Provider/Github/ProviderTest.php @@ -188,4 +188,142 @@ public function testGetCommitsWithDateRange(): void $provider->getCommits($since, $until); } + + public function testGetCommitFileNames(): void + { + $commitResponse = [ + 'sha' => 'abc123', + 'files' => [ + ['filename' => 'composer.json', 'patch' => 'some diff'], + ['filename' => 'src/Controller.php', 'patch' => 'another diff'], + ], + ]; + + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn($commitResponse); + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'GET', + $this->stringContains('/commits/abc123'), + $this->anything() + ) + ->willReturn($response); + + $provider = new Provider( + $this->httpClient, + $this->parser, + 'https://api.github.com', + 'example', + 'project', + ); + + $files = $provider->getCommitFileNames('abc123'); + + $this->assertCount(2, $files); + $this->assertContains('composer.json', $files); + $this->assertContains('src/Controller.php', $files); + } + + 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']; + }) + ) + ->willReturn($response); + + $provider = new Provider( + $this->httpClient, + $this->parser, + 'https://api.github.com', + 'example', + 'project', + 'ghp_xxxx', + ); + + $provider->getCommitFileNames('abc123'); + } + + public function testGetCommitDiff(): void + { + $patchContent = '@@ -1,3 +1,4 @@\n+new line'; + $commitResponse = [ + 'sha' => 'abc123', + 'files' => [ + ['filename' => 'composer.json', 'patch' => $patchContent], + ['filename' => 'README.md', 'patch' => 'readme diff'], + ], + ]; + + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn($commitResponse); + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->willReturn($response); + + $provider = new Provider( + $this->httpClient, + $this->parser, + 'https://api.github.com', + 'example', + 'project', + ); + + $diffs = $provider->getCommitDiff('abc123'); + + $this->assertCount(2, $diffs); + $this->assertArrayHasKey('composer.json', $diffs); + $this->assertArrayHasKey('README.md', $diffs); + $this->assertSame($patchContent, $diffs['composer.json']); + } + + public function testGetCommitDiffExcludesFilesWithoutPatch(): void + { + $commitResponse = [ + 'sha' => 'abc123', + 'files' => [ + ['filename' => 'composer.json', 'patch' => 'diff content'], + ['filename' => 'binary.png'], // No patch for binary files + ], + ]; + + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn($commitResponse); + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->willReturn($response); + + $provider = new Provider( + $this->httpClient, + $this->parser, + 'https://api.github.com', + 'example', + 'project', + ); + + $diffs = $provider->getCommitDiff('abc123'); + + $this->assertCount(1, $diffs); + $this->assertArrayHasKey('composer.json', $diffs); + $this->assertArrayNotHasKey('binary.png', $diffs); + } } diff --git a/tests/Unit/Provider/Gitlab/ProviderTest.php b/tests/Unit/Provider/Gitlab/ProviderTest.php index 7fede87..8febcaa 100644 --- a/tests/Unit/Provider/Gitlab/ProviderTest.php +++ b/tests/Unit/Provider/Gitlab/ProviderTest.php @@ -182,4 +182,125 @@ public function testGetCommitsWithDateRange(): void $provider->getCommits($since, $until); } + + public function testGetCommitFileNames(): void + { + $diffResponse = [ + ['new_path' => 'composer.json', 'old_path' => 'composer.json', 'diff' => 'some diff'], + ['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') + ->with( + 'GET', + $this->stringContains('/repository/commits/abc123/diff'), + $this->anything() + ) + ->willReturn($response); + + $provider = new Provider( + $this->httpClient, + $this->parser, + 'https://gitlab.example.com', + '123', + ); + + $files = $provider->getCommitFileNames('abc123'); + + $this->assertCount(2, $files); + $this->assertContains('composer.json', $files); + $this->assertContains('src/Controller.php', $files); + } + + 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']; + }) + ) + ->willReturn($response); + + $provider = new Provider( + $this->httpClient, + $this->parser, + 'https://gitlab.example.com', + '123', + 'glpat-xxxx', + ); + + $provider->getCommitFileNames('abc123'); + } + + public function testGetCommitDiff(): void + { + $diffContent = '@@ -1,3 +1,4 @@\n+new line'; + $diffResponse = [ + ['new_path' => 'composer.json', 'old_path' => 'composer.json', 'diff' => $diffContent], + ['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); + + $provider = new Provider( + $this->httpClient, + $this->parser, + 'https://gitlab.example.com', + '123', + ); + + $diffs = $provider->getCommitDiff('abc123'); + + $this->assertCount(2, $diffs); + $this->assertArrayHasKey('composer.json', $diffs); + $this->assertArrayHasKey('README.md', $diffs); + $this->assertSame($diffContent, $diffs['composer.json']); + } + + public function testGetCommitDiffUsesNewPathOverOldPath(): void + { + $diffResponse = [ + ['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); + + $provider = new Provider( + $this->httpClient, + $this->parser, + 'https://gitlab.example.com', + '123', + ); + + $diffs = $provider->getCommitDiff('abc123'); + + $this->assertArrayHasKey('renamed.json', $diffs); + $this->assertArrayNotHasKey('original.json', $diffs); + } } diff --git a/tests/Unit/Service/DependencyDetectionServiceTest.php b/tests/Unit/Service/DependencyDetectionServiceTest.php new file mode 100644 index 0000000..d00e244 --- /dev/null +++ b/tests/Unit/Service/DependencyDetectionServiceTest.php @@ -0,0 +1,261 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Spiriit\Bundle\CommitHistoryBundle\Tests\Unit\Service; + +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; + +class DependencyDetectionServiceTest extends TestCase +{ + private ArrayAdapter $cache; + private ProviderInterface&MockObject $provider; + + protected function setUp(): void + { + $this->cache = new ArrayAdapter(); + $this->provider = $this->createMock(ProviderInterface::class); + } + + public function testHasDependencyChangesReturnsTrueWhenDependencyFileModified(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitFileNames') + ->with('abc123') + ->willReturn(['src/Controller.php', 'composer.json', 'README.md']); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json', 'composer.lock'], + true, + ); + + $result = $service->hasDependencyChanges('abc123'); + + $this->assertTrue($result); + } + + public function testHasDependencyChangesReturnsFalseWhenNoDependencyFileModified(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitFileNames') + ->with('abc123') + ->willReturn(['src/Controller.php', 'README.md']); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json', 'composer.lock'], + true, + ); + + $result = $service->hasDependencyChanges('abc123'); + + $this->assertFalse($result); + } + + public function testHasDependencyChangesHandlesPathsWithDirectories(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitFileNames') + ->with('abc123') + ->willReturn(['src/Controller.php', 'packages/my-package/composer.json']); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json', 'composer.lock'], + true, + ); + + $result = $service->hasDependencyChanges('abc123'); + + $this->assertTrue($result); + } + + public function testHasDependencyChangesReturnsFalseWhenTrackingDisabled(): void + { + $this->provider + ->expects($this->never()) + ->method('getCommitFileNames'); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json'], + false, + ); + + $result = $service->hasDependencyChanges('abc123'); + + $this->assertFalse($result); + } + + public function testHasDependencyChangesCachesResult(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitFileNames') + ->with('abc123') + ->willReturn(['composer.json']); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json'], + true, + ); + + // First call - fetches from provider + $result1 = $service->hasDependencyChanges('abc123'); + + // Second call - should use cache + $result2 = $service->hasDependencyChanges('abc123'); + + $this->assertTrue($result1); + $this->assertTrue($result2); + } + + public function testHasDependencyChangesReturnsFalseOnProviderError(): void + { + $this->provider + ->expects($this->once()) + ->method('getCommitFileNames') + ->willThrowException(new \RuntimeException('API error')); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json'], + true, + ); + + $result = $service->hasDependencyChanges('abc123'); + + $this->assertFalse($result); + } + + public function testDetectForCommitsUpdatesAllCommits(): void + { + $commits = [ + new Commit('abc123', 'Commit 1', new \DateTimeImmutable(), 'Author', 'https://example.com'), + new Commit('def456', 'Commit 2', new \DateTimeImmutable(), 'Author', 'https://example.com'), + ]; + + $this->provider + ->expects($this->exactly(2)) + ->method('getCommitFileNames') + ->willReturnCallback(function (string $commitId) { + return 'abc123' === $commitId ? ['composer.json'] : ['README.md']; + }); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json'], + true, + ); + + $result = $service->detectForCommits($commits); + + $this->assertCount(2, $result); + $this->assertTrue($result[0]->hasDependenciesChanges); + $this->assertFalse($result[1]->hasDependenciesChanges); + } + + public function testDetectForCommitsReturnsUnmodifiedCommitsWhenDisabled(): void + { + $commits = [ + new Commit('abc123', 'Commit 1', new \DateTimeImmutable(), 'Author', 'https://example.com'), + ]; + + $this->provider + ->expects($this->never()) + ->method('getCommitFileNames'); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json'], + false, + ); + + $result = $service->detectForCommits($commits); + + $this->assertCount(1, $result); + $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(); + + $this->assertSame('spiriit_commit_history_has_deps_', $prefix); + } + + public function testSupportsMultipleDependencyFileTypes(): void + { + $this->provider + ->method('getCommitFileNames') + ->willReturnOnConsecutiveCalls( + ['package.json'], + ['package-lock.json'], + ['composer.lock'], + ); + + $service = new DependencyDetectionService( + $this->provider, + $this->cache, + ['composer.json', 'composer.lock', 'package.json', 'package-lock.json'], + true, + ); + + $this->assertTrue($service->hasDependencyChanges('commit1')); + $this->assertTrue($service->hasDependencyChanges('commit2')); + $this->assertTrue($service->hasDependencyChanges('commit3')); + } +} diff --git a/tests/Unit/Service/DiffParser/ComposerDiffParserTest.php b/tests/Unit/Service/DiffParser/ComposerDiffParserTest.php new file mode 100644 index 0000000..fb0e61d --- /dev/null +++ b/tests/Unit/Service/DiffParser/ComposerDiffParserTest.php @@ -0,0 +1,224 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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; + +class ComposerDiffParserTest extends TestCase +{ + private ComposerDiffParser $parser; + + protected function setUp(): void + { + $this->parser = new ComposerDiffParser(); + } + + public function testSupportsComposerJson(): void + { + $this->assertTrue($this->parser->supports('composer.json')); + $this->assertTrue($this->parser->supports('/path/to/composer.json')); + } + + public function testSupportsComposerLock(): void + { + $this->assertTrue($this->parser->supports('composer.lock')); + $this->assertTrue($this->parser->supports('/path/to/composer.lock')); + } + + public function testDoesNotSupportOtherFiles(): void + { + $this->assertFalse($this->parser->supports('package.json')); + $this->assertFalse($this->parser->supports('package-lock.json')); + $this->assertFalse($this->parser->supports('composer.txt')); + } + + public function testParseComposerJsonAddedPackage(): void + { + $diff = <<<'DIFF' +@@ -10,6 +10,7 @@ + "require": { + "php": "^8.2", ++ "symfony/http-client": "^7.0", + "symfony/framework-bundle": "^7.0" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'composer.json'); + + $this->assertCount(1, $changes); + $this->assertSame('symfony/http-client', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_ADDED, $changes[0]->type); + $this->assertNull($changes[0]->oldVersion); + $this->assertSame('^7.0', $changes[0]->newVersion); + } + + public function testParseComposerJsonRemovedPackage(): void + { + $diff = <<<'DIFF' +@@ -10,7 +10,6 @@ + "require": { + "php": "^8.2", +- "old/package": "^1.0", + "symfony/framework-bundle": "^7.0" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'composer.json'); + + $this->assertCount(1, $changes); + $this->assertSame('old/package', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_REMOVED, $changes[0]->type); + $this->assertSame('^1.0', $changes[0]->oldVersion); + $this->assertNull($changes[0]->newVersion); + } + + public function testParseComposerJsonUpdatedPackage(): void + { + $diff = <<<'DIFF' +@@ -10,7 +10,7 @@ + "require": { + "php": "^8.2", +- "symfony/http-client": "^6.4", ++ "symfony/http-client": "^7.0", + "symfony/framework-bundle": "^7.0" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'composer.json'); + + $this->assertCount(1, $changes); + $this->assertSame('symfony/http-client', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_UPDATED, $changes[0]->type); + $this->assertSame('^6.4', $changes[0]->oldVersion); + $this->assertSame('^7.0', $changes[0]->newVersion); + } + + public function testParseComposerJsonSkipsNonDependencyKeys(): void + { + $diff = <<<'DIFF' +@@ -1,5 +1,5 @@ + { +- "name": "old/name", ++ "name": "new/name", +- "description": "Old description", ++ "description": "New description", + "require": {} + } +DIFF; + + $changes = $this->parser->parse($diff, 'composer.json'); + + $this->assertCount(0, $changes); + } + + public function testParseComposerJsonSkipsNonPackageNames(): void + { + $diff = <<<'DIFF' +@@ -1,5 +1,5 @@ + "require": { +- "php": "^8.1", ++ "php": "^8.2", + } +DIFF; + + $changes = $this->parser->parse($diff, 'composer.json'); + + // "php" doesn't contain "/" so it's skipped + $this->assertCount(0, $changes); + } + + public function testParseComposerLockAddedPackage(): void + { + $diff = <<<'DIFF' +@@ -100,6 +100,20 @@ ++ "name": "symfony/http-client", ++ "version": "v7.0.0", ++ "source": { +DIFF; + + $changes = $this->parser->parse($diff, 'composer.lock'); + + $this->assertCount(1, $changes); + $this->assertSame('symfony/http-client', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_ADDED, $changes[0]->type); + $this->assertSame('v7.0.0', $changes[0]->newVersion); + } + + public function testParseComposerLockUpdatedPackage(): void + { + $diff = <<<'DIFF' +@@ -100,7 +100,7 @@ +- "name": "symfony/http-client", +- "version": "v6.4.0", ++ "name": "symfony/http-client", ++ "version": "v7.0.0", + "source": { +DIFF; + + $changes = $this->parser->parse($diff, 'composer.lock'); + + $this->assertCount(1, $changes); + $this->assertSame('symfony/http-client', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_UPDATED, $changes[0]->type); + $this->assertSame('v6.4.0', $changes[0]->oldVersion); + $this->assertSame('v7.0.0', $changes[0]->newVersion); + } + + public function testParseMultipleChanges(): void + { + $diff = <<<'DIFF' +@@ -10,9 +10,10 @@ + "require": { + "php": "^8.2", ++ "symfony/http-client": "^7.0", +- "old/package": "^1.0", +- "another/package": "^2.0", ++ "another/package": "^3.0", + "symfony/framework-bundle": "^7.0" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'composer.json'); + + $this->assertCount(3, $changes); + + $changesByName = []; + foreach ($changes as $change) { + $changesByName[$change->name] = $change; + } + + $this->assertSame(DependencyChange::TYPE_ADDED, $changesByName['symfony/http-client']->type); + $this->assertSame(DependencyChange::TYPE_REMOVED, $changesByName['old/package']->type); + $this->assertSame(DependencyChange::TYPE_UPDATED, $changesByName['another/package']->type); + } + + public function testParseEmptyDiff(): void + { + $changes = $this->parser->parse('', 'composer.json'); + + $this->assertCount(0, $changes); + } + + public function testSourceFileIsPreserved(): void + { + $diff = <<<'DIFF' ++ "symfony/http-client": "^7.0", +DIFF; + + $changes = $this->parser->parse($diff, '/path/to/composer.json'); + + $this->assertCount(1, $changes); + $this->assertSame('/path/to/composer.json', $changes[0]->sourceFile); + } +} diff --git a/tests/Unit/Service/DiffParser/DiffParserRegistryTest.php b/tests/Unit/Service/DiffParser/DiffParserRegistryTest.php new file mode 100644 index 0000000..21ad5a4 --- /dev/null +++ b/tests/Unit/Service/DiffParser/DiffParserRegistryTest.php @@ -0,0 +1,118 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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; + +class DiffParserRegistryTest extends TestCase +{ + private DiffParserRegistry $registry; + + protected function setUp(): void + { + $this->registry = new DiffParserRegistry([ + new ComposerDiffParser(), + new PackageDiffParser(), + ]); + } + + public function testSupportsComposerFiles(): void + { + $this->assertTrue($this->registry->supports('composer.json')); + $this->assertTrue($this->registry->supports('composer.lock')); + } + + public function testSupportsPackageFiles(): void + { + $this->assertTrue($this->registry->supports('package.json')); + $this->assertTrue($this->registry->supports('package-lock.json')); + } + + public function testDoesNotSupportUnknownFiles(): void + { + $this->assertFalse($this->registry->supports('unknown.txt')); + $this->assertFalse($this->registry->supports('README.md')); + } + + public function testParseUsesCorrectParser(): void + { + $composerDiff = '+ "symfony/http-client": "^7.0",'; + $packageDiff = '+ "lodash": "^4.17.21",'; + + $composerChanges = $this->registry->parse($composerDiff, 'composer.json'); + $packageChanges = $this->registry->parse($packageDiff, 'package.json'); + + $this->assertCount(1, $composerChanges); + $this->assertSame('symfony/http-client', $composerChanges[0]->name); + + $this->assertCount(1, $packageChanges); + $this->assertSame('lodash', $packageChanges[0]->name); + } + + public function testParseReturnsEmptyArrayForUnsupportedFile(): void + { + $changes = $this->registry->parse('some content', 'unknown.txt'); + + $this->assertCount(0, $changes); + } + + public function testParseAllParsesMultipleFiles(): void + { + $diffs = [ + 'composer.json' => '+ "symfony/http-client": "^7.0",', + 'package.json' => '+ "lodash": "^4.17.21",', + ]; + + $changes = $this->registry->parseAll($diffs); + + $this->assertCount(2, $changes); + $this->assertContainsOnlyInstancesOf(DependencyChange::class, $changes); + + $names = array_map(fn ($c) => $c->name, $changes); + $this->assertContains('symfony/http-client', $names); + $this->assertContains('lodash', $names); + } + + public function testParseAllSkipsUnsupportedFiles(): void + { + $diffs = [ + 'composer.json' => '+ "symfony/http-client": "^7.0",', + 'README.md' => '+ Some text', + 'unknown.txt' => '+ More text', + ]; + + $changes = $this->registry->parseAll($diffs); + + $this->assertCount(1, $changes); + $this->assertSame('symfony/http-client', $changes[0]->name); + } + + public function testParseAllWithEmptyDiffs(): void + { + $changes = $this->registry->parseAll([]); + + $this->assertCount(0, $changes); + } + + public function testRegistryWithNoParsers(): void + { + $emptyRegistry = new DiffParserRegistry([]); + + $this->assertFalse($emptyRegistry->supports('composer.json')); + $this->assertCount(0, $emptyRegistry->parse('some diff', 'composer.json')); + $this->assertCount(0, $emptyRegistry->parseAll(['composer.json' => 'diff'])); + } +} diff --git a/tests/Unit/Service/DiffParser/PackageDiffParserTest.php b/tests/Unit/Service/DiffParser/PackageDiffParserTest.php new file mode 100644 index 0000000..c545c99 --- /dev/null +++ b/tests/Unit/Service/DiffParser/PackageDiffParserTest.php @@ -0,0 +1,225 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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; + +class PackageDiffParserTest extends TestCase +{ + private PackageDiffParser $parser; + + protected function setUp(): void + { + $this->parser = new PackageDiffParser(); + } + + public function testSupportsPackageJson(): void + { + $this->assertTrue($this->parser->supports('package.json')); + $this->assertTrue($this->parser->supports('/path/to/package.json')); + } + + public function testSupportsPackageLockJson(): void + { + $this->assertTrue($this->parser->supports('package-lock.json')); + $this->assertTrue($this->parser->supports('/path/to/package-lock.json')); + } + + public function testDoesNotSupportOtherFiles(): void + { + $this->assertFalse($this->parser->supports('composer.json')); + $this->assertFalse($this->parser->supports('composer.lock')); + $this->assertFalse($this->parser->supports('package.txt')); + } + + public function testParsePackageJsonAddedPackage(): void + { + $diff = <<<'DIFF' +@@ -10,6 +10,7 @@ + "dependencies": { + "express": "^4.18.0", ++ "lodash": "^4.17.21", + "react": "^18.0.0" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'package.json'); + + $this->assertCount(1, $changes); + $this->assertSame('lodash', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_ADDED, $changes[0]->type); + $this->assertNull($changes[0]->oldVersion); + $this->assertSame('^4.17.21', $changes[0]->newVersion); + } + + public function testParsePackageJsonRemovedPackage(): void + { + $diff = <<<'DIFF' +@@ -10,7 +10,6 @@ + "dependencies": { + "express": "^4.18.0", +- "lodash": "^4.17.21", + "react": "^18.0.0" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'package.json'); + + $this->assertCount(1, $changes); + $this->assertSame('lodash', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_REMOVED, $changes[0]->type); + $this->assertSame('^4.17.21', $changes[0]->oldVersion); + $this->assertNull($changes[0]->newVersion); + } + + public function testParsePackageJsonUpdatedPackage(): void + { + $diff = <<<'DIFF' +@@ -10,7 +10,7 @@ + "dependencies": { + "express": "^4.18.0", +- "react": "^17.0.0", ++ "react": "^18.0.0", + "lodash": "^4.17.21" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'package.json'); + + $this->assertCount(1, $changes); + $this->assertSame('react', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_UPDATED, $changes[0]->type); + $this->assertSame('^17.0.0', $changes[0]->oldVersion); + $this->assertSame('^18.0.0', $changes[0]->newVersion); + } + + public function testParsePackageJsonSkipsNonDependencyKeys(): void + { + $diff = <<<'DIFF' +@@ -1,5 +1,5 @@ + { +- "name": "old-name", ++ "name": "new-name", +- "version": "1.0.0", ++ "version": "2.0.0", +- "description": "Old description", ++ "description": "New description" + } +DIFF; + + $changes = $this->parser->parse($diff, 'package.json'); + + $this->assertCount(0, $changes); + } + + public function testParsePackageJsonSkipsUrlVersions(): void + { + $diff = <<<'DIFF' +@@ -10,6 +10,7 @@ + "dependencies": { ++ "my-package": "https://github.com/user/repo.git", ++ "local-package": "file:../local", ++ "git-package": "git+ssh://git@github.com/user/repo.git" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'package.json'); + + $this->assertCount(0, $changes); + } + + public function testParsePackageLockAddedPackage(): void + { + $diff = <<<'DIFF' +@@ -100,6 +100,12 @@ ++ "node_modules/lodash": { ++ "version": "4.17.21", ++ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" ++ }, +DIFF; + + $changes = $this->parser->parse($diff, 'package-lock.json'); + + $this->assertCount(1, $changes); + $this->assertSame('lodash', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_ADDED, $changes[0]->type); + $this->assertSame('4.17.21', $changes[0]->newVersion); + } + + public function testParsePackageLockUpdatedPackage(): void + { + $diff = <<<'DIFF' +@@ -100,7 +100,7 @@ +- "node_modules/lodash": { +- "version": "4.17.20", ++ "node_modules/lodash": { ++ "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash.tgz" +DIFF; + + $changes = $this->parser->parse($diff, 'package-lock.json'); + + $this->assertCount(1, $changes); + $this->assertSame('lodash', $changes[0]->name); + $this->assertSame(DependencyChange::TYPE_UPDATED, $changes[0]->type); + $this->assertSame('4.17.20', $changes[0]->oldVersion); + $this->assertSame('4.17.21', $changes[0]->newVersion); + } + + public function testParseMultipleChanges(): void + { + $diff = <<<'DIFF' +@@ -10,9 +10,10 @@ + "dependencies": { ++ "lodash": "^4.17.21", +- "old-package": "^1.0.0", +- "react": "^17.0.0", ++ "react": "^18.0.0", + "express": "^4.18.0" + }, +DIFF; + + $changes = $this->parser->parse($diff, 'package.json'); + + $this->assertCount(3, $changes); + + $changesByName = []; + foreach ($changes as $change) { + $changesByName[$change->name] = $change; + } + + $this->assertSame(DependencyChange::TYPE_ADDED, $changesByName['lodash']->type); + $this->assertSame(DependencyChange::TYPE_REMOVED, $changesByName['old-package']->type); + $this->assertSame(DependencyChange::TYPE_UPDATED, $changesByName['react']->type); + } + + public function testParseEmptyDiff(): void + { + $changes = $this->parser->parse('', 'package.json'); + + $this->assertCount(0, $changes); + } + + public function testSourceFileIsPreserved(): void + { + $diff = <<<'DIFF' ++ "lodash": "^4.17.21", +DIFF; + + $changes = $this->parser->parse($diff, '/path/to/package.json'); + + $this->assertCount(1, $changes); + $this->assertSame('/path/to/package.json', $changes[0]->sourceFile); + } +}