From 030514198bfba87c7aa1cdb1ca9b92a95555a0e7 Mon Sep 17 00:00:00 2001 From: Maarten Bode Date: Sat, 25 Apr 2026 10:40:21 +0200 Subject: [PATCH] fix(sync): use git commit date for package version released_at --- .../Repository/Actions/SyncRefAction.php | 30 ++++- .../Interfaces/GitProviderInterface.php | 4 + .../GitProviders/BitbucketProvider.php | 30 +++++ .../GitProviders/CachedGitProvider.php | 24 ++++ .../GitProviders/GenericGitProvider.php | 8 ++ .../Services/GitProviders/GitHubProvider.php | 30 +++++ .../Services/GitProviders/GitLabProvider.php | 31 +++++ .../Domains/Repository/SyncRefActionTest.php | 117 ++++++++++++++++++ 8 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/Domains/Repository/SyncRefActionTest.php diff --git a/app/Domains/Repository/Actions/SyncRefAction.php b/app/Domains/Repository/Actions/SyncRefAction.php index 218143a..60f34c0 100644 --- a/app/Domains/Repository/Actions/SyncRefAction.php +++ b/app/Domains/Repository/Actions/SyncRefAction.php @@ -8,6 +8,7 @@ use App\Models\Package; use App\Models\PackageVersion; use App\Models\Repository; +use Carbon\CarbonImmutable; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -57,7 +58,9 @@ public function handle( } } - $result = DB::transaction(function () use ($metadata, $ref, $package, $repository, $provider): array { + $releasedAt = $this->resolveReleasedAt($provider, $ref, $metadata); + + $result = DB::transaction(function () use ($metadata, $ref, $package, $repository, $provider, $releasedAt): array { if (! $package) { $package = $this->findOrCreatePackage->handle($repository, $metadata->name); } @@ -77,6 +80,7 @@ public function handle( 'source_url' => $sourceUrl, 'source_reference' => $ref->commit, 'source_tag' => $ref->name, + 'released_at' => $releasedAt, ]); return ['status' => 'updated', 'version' => $version, 'package' => $package]; @@ -90,7 +94,7 @@ public function handle( 'source_url' => $sourceUrl, 'source_reference' => $ref->commit, 'source_tag' => $ref->name, - 'released_at' => now(), + 'released_at' => $releasedAt, ]); return ['status' => 'added', 'version' => $version, 'package' => $package]; @@ -103,6 +107,28 @@ public function handle( return $result['status']; } + protected function resolveReleasedAt( + GitProviderInterface $provider, + RefData $ref, + ComposerMetadataData $metadata, + ): CarbonImmutable { + $commitDate = $provider->getCommitDate($ref->commit); + + if ($commitDate) { + return $commitDate; + } + + if (isset($metadata->composerJson['time']) && is_string($metadata->composerJson['time'])) { + try { + return CarbonImmutable::parse($metadata->composerJson['time']); + } catch (\Exception) { + // fall through to now() + } + } + + return CarbonImmutable::now(); + } + protected function createDistForVersion( GitProviderInterface $provider, PackageVersion $version, diff --git a/app/Domains/Repository/Contracts/Interfaces/GitProviderInterface.php b/app/Domains/Repository/Contracts/Interfaces/GitProviderInterface.php index 0586054..eb972b2 100644 --- a/app/Domains/Repository/Contracts/Interfaces/GitProviderInterface.php +++ b/app/Domains/Repository/Contracts/Interfaces/GitProviderInterface.php @@ -2,6 +2,8 @@ namespace App\Domains\Repository\Contracts\Interfaces; +use Carbon\CarbonImmutable; + interface GitProviderInterface { /** @@ -16,6 +18,8 @@ public function getBranches(): array; public function getFileContent(string $ref, string $path): ?string; + public function getCommitDate(string $ref): ?CarbonImmutable; + public function validateCredentials(): bool; public function getRepositoryIdentifier(): string; diff --git a/app/Domains/Repository/Services/GitProviders/BitbucketProvider.php b/app/Domains/Repository/Services/GitProviders/BitbucketProvider.php index 6f7dff5..508beac 100644 --- a/app/Domains/Repository/Services/GitProviders/BitbucketProvider.php +++ b/app/Domains/Repository/Services/GitProviders/BitbucketProvider.php @@ -4,6 +4,7 @@ use App\Domains\Repository\Contracts\Data\RepositorySuggestionData; use App\Domains\Repository\Exceptions\GitProviderException; +use Carbon\CarbonImmutable; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; @@ -183,6 +184,35 @@ public function getFileContent(string $ref, string $path): ?string } } + public function getCommitDate(string $ref): ?CarbonImmutable + { + try { + $response = $this->http->get("/repositories/{$this->repositoryIdentifier}/commit/{$ref}"); + + if ($response->status() === 404) { + return null; + } + + if ($response->failed()) { + throw new GitProviderException( + "Failed to fetch commit from Bitbucket: {$response->body()}" + ); + } + + $date = $response->json('date'); + + return $date ? CarbonImmutable::parse($date) : null; + } catch (\Exception $e) { + Log::warning('Bitbucket API error fetching commit date', [ + 'repository' => $this->repositoryIdentifier, + 'ref' => $ref, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + public function validateCredentials(): bool { try { diff --git a/app/Domains/Repository/Services/GitProviders/CachedGitProvider.php b/app/Domains/Repository/Services/GitProviders/CachedGitProvider.php index bbc6fdf..001321a 100644 --- a/app/Domains/Repository/Services/GitProviders/CachedGitProvider.php +++ b/app/Domains/Repository/Services/GitProviders/CachedGitProvider.php @@ -4,6 +4,7 @@ use App\Domains\Repository\Contracts\Interfaces\GitProviderInterface; use App\Domains\Repository\Exceptions\GitProviderException; +use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Process; class CachedGitProvider implements GitProviderInterface @@ -36,6 +37,29 @@ public function getFileContent(string $ref, string $path): ?string return $result->output(); } + public function getCommitDate(string $ref): ?CarbonImmutable + { + $result = Process::path($this->clonePath) + ->env(['GIT_TERMINAL_PROMPT' => '0']) + ->run(['git', 'show', '-s', '--format=%cI', $ref]); + + if ($result->failed()) { + return null; + } + + $date = trim($result->output()); + + if ($date === '') { + return null; + } + + try { + return CarbonImmutable::parse($date); + } catch (\Exception) { + return null; + } + } + public function validateCredentials(): bool { return is_dir($this->clonePath); diff --git a/app/Domains/Repository/Services/GitProviders/GenericGitProvider.php b/app/Domains/Repository/Services/GitProviders/GenericGitProvider.php index d9ba62a..ad7b741 100644 --- a/app/Domains/Repository/Services/GitProviders/GenericGitProvider.php +++ b/app/Domains/Repository/Services/GitProviders/GenericGitProvider.php @@ -3,6 +3,7 @@ namespace App\Domains\Repository\Services\GitProviders; use App\Domains\Repository\Exceptions\GitProviderException; +use Carbon\CarbonImmutable; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; @@ -74,6 +75,13 @@ public function getFileContent(string $ref, string $path): ?string } } + public function getCommitDate(string $ref): ?CarbonImmutable + { + // Generic Git only has ls-remote without a clone, so commit dates are not + // available here. The SyncRefAction path runs against CachedGitProvider. + return null; + } + public function validateCredentials(): bool { try { diff --git a/app/Domains/Repository/Services/GitProviders/GitHubProvider.php b/app/Domains/Repository/Services/GitProviders/GitHubProvider.php index f9e1fda..377726f 100644 --- a/app/Domains/Repository/Services/GitProviders/GitHubProvider.php +++ b/app/Domains/Repository/Services/GitProviders/GitHubProvider.php @@ -4,6 +4,7 @@ use App\Domains\Repository\Contracts\Data\RepositorySuggestionData; use App\Domains\Repository\Exceptions\GitProviderException; +use Carbon\CarbonImmutable; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; @@ -226,6 +227,35 @@ public function getFileContent(string $ref, string $path): ?string } } + public function getCommitDate(string $ref): ?CarbonImmutable + { + try { + $response = $this->http->get("/repos/{$this->repositoryIdentifier}/commits/{$ref}"); + + if ($response->status() === 404) { + return null; + } + + if ($response->failed()) { + throw new GitProviderException( + "Failed to fetch commit from GitHub: {$response->body()}" + ); + } + + $date = $response->json('commit.committer.date') ?? $response->json('commit.author.date'); + + return $date ? CarbonImmutable::parse($date) : null; + } catch (\Exception $e) { + Log::warning('GitHub API error fetching commit date', [ + 'repository' => $this->repositoryIdentifier, + 'ref' => $ref, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + public function validateCredentials(): bool { try { diff --git a/app/Domains/Repository/Services/GitProviders/GitLabProvider.php b/app/Domains/Repository/Services/GitProviders/GitLabProvider.php index 8f6c795..93a83c7 100644 --- a/app/Domains/Repository/Services/GitProviders/GitLabProvider.php +++ b/app/Domains/Repository/Services/GitProviders/GitLabProvider.php @@ -4,6 +4,7 @@ use App\Domains\Repository\Contracts\Data\RepositorySuggestionData; use App\Domains\Repository\Exceptions\GitProviderException; +use Carbon\CarbonImmutable; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; @@ -205,6 +206,36 @@ public function getFileContent(string $ref, string $path): ?string } } + public function getCommitDate(string $ref): ?CarbonImmutable + { + try { + $projectPath = $this->getEncodedProjectPath(); + $response = $this->http->get("/projects/{$projectPath}/repository/commits/{$ref}"); + + if ($response->status() === 404) { + return null; + } + + if ($response->failed()) { + throw new GitProviderException( + "Failed to fetch commit from GitLab: {$response->body()}" + ); + } + + $date = $response->json('committed_date') ?? $response->json('authored_date'); + + return $date ? CarbonImmutable::parse($date) : null; + } catch (\Exception $e) { + Log::warning('GitLab API error fetching commit date', [ + 'repository' => $this->repositoryIdentifier, + 'ref' => $ref, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + public function validateCredentials(): bool { try { diff --git a/tests/Feature/Domains/Repository/SyncRefActionTest.php b/tests/Feature/Domains/Repository/SyncRefActionTest.php new file mode 100644 index 0000000..21f1556 --- /dev/null +++ b/tests/Feature/Domains/Repository/SyncRefActionTest.php @@ -0,0 +1,117 @@ +shouldReceive('getFileContent') + ->with(Mockery::any(), 'composer.json') + ->andReturn(json_encode($composerJson)); + $mock->shouldReceive('getCommitDate')->andReturn($commitDate); + $mock->shouldReceive('getRepositoryUrl')->andReturn('git@github.com:vendor/repo.git'); + + return $mock; +} + +it('uses the commit date from the provider as released_at', function () { + $organization = Organization::factory()->create(); + $repository = Repository::factory()->forOrganization($organization)->create(); + + $commitDate = CarbonImmutable::parse('2025-12-01T10:30:00Z'); + $provider = mockProviderForRefSync([ + 'name' => 'vendor/package', + 'type' => 'library', + ], $commitDate); + + $result = app(SyncRefAction::class)->handle( + $provider, + $repository, + new RefData(name: 'v1.0.0', commit: 'abc123') + ); + + expect($result)->toBe('added'); + expect(PackageVersion::first()->released_at->equalTo($commitDate))->toBeTrue(); +}); + +it('falls back to composer.json time when the provider returns no commit date', function () { + $organization = Organization::factory()->create(); + $repository = Repository::factory()->forOrganization($organization)->create(); + + $provider = mockProviderForRefSync([ + 'name' => 'vendor/package', + 'type' => 'library', + 'time' => '2025-11-15T08:00:00Z', + ], null); + + app(SyncRefAction::class)->handle( + $provider, + $repository, + new RefData(name: 'v1.0.0', commit: 'abc123') + ); + + $expected = CarbonImmutable::parse('2025-11-15T08:00:00Z'); + expect(PackageVersion::first()->released_at->equalTo($expected))->toBeTrue(); +}); + +it('falls back to now() when neither commit date nor composer.json time is available', function () { + $organization = Organization::factory()->create(); + $repository = Repository::factory()->forOrganization($organization)->create(); + + $provider = mockProviderForRefSync([ + 'name' => 'vendor/package', + 'type' => 'library', + ], null); + + CarbonImmutable::setTestNow('2026-04-23T12:00:00Z'); + + app(SyncRefAction::class)->handle( + $provider, + $repository, + new RefData(name: 'v1.0.0', commit: 'abc123') + ); + + $version = PackageVersion::first(); + expect($version->released_at->equalTo(CarbonImmutable::parse('2026-04-23T12:00:00Z')))->toBeTrue(); + + CarbonImmutable::setTestNow(); +}); + +it('refreshes released_at when an existing version is updated with a new commit SHA', function () { + $organization = Organization::factory()->create(); + $repository = Repository::factory()->forOrganization($organization)->create(); + + $firstDate = CarbonImmutable::parse('2025-10-01T09:00:00Z'); + $provider = mockProviderForRefSync([ + 'name' => 'vendor/package', + 'type' => 'library', + ], $firstDate); + + app(SyncRefAction::class)->handle( + $provider, + $repository, + new RefData(name: 'v1.0.0', commit: 'first-sha') + ); + + $secondDate = CarbonImmutable::parse('2025-12-20T14:30:00Z'); + $updatedProvider = mockProviderForRefSync([ + 'name' => 'vendor/package', + 'type' => 'library', + ], $secondDate); + + $result = app(SyncRefAction::class)->handle( + $updatedProvider, + $repository, + new RefData(name: 'v1.0.0', commit: 'second-sha') + ); + + expect($result)->toBe('updated'); + expect(PackageVersion::count())->toBe(1); + expect(PackageVersion::first()->released_at->equalTo($secondDate))->toBeTrue(); +});