diff --git a/integrations/bitbucket/database/migrations/2026_07_16_000001_seed_rebase_event_type.php b/integrations/bitbucket/database/migrations/2026_07_16_000001_seed_rebase_event_type.php new file mode 100644 index 0000000..11f5274 --- /dev/null +++ b/integrations/bitbucket/database/migrations/2026_07_16_000001_seed_rebase_event_type.php @@ -0,0 +1,19 @@ + 'rebase']); + $eventType->weight = 10; + $eventType->save(); + } + + public function down(): void + { + EventType::where('id', 'rebase')->delete(); + } +}; diff --git a/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php b/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php index 7ec7470..6f35b4e 100644 --- a/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php +++ b/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php @@ -48,10 +48,22 @@ private function handlePush(TicketService $ticketService): void { foreach ($this->payload['push']['changes'] ?? [] as $change) { $branchName = is_string($change['new']['name'] ?? null) ? $change['new']['name'] : null; + $forced = (bool) ($change['forced'] ?? false); + $knownCommits = []; foreach ($change['commits'] ?? [] as $commit) { + if ($this->isKnownCommit($commit, $forced)) { + $knownCommits[] = $commit; + + continue; + } + $this->createEventFromCommit($commit, $branchName, $ticketService); } + + if ($knownCommits !== []) { + $this->createRebaseEvent($knownCommits, $branchName); + } } } @@ -137,7 +149,9 @@ private function createEvent(User $user, string $eventTypeId, string $title, Car 'budget_id' => $budgetId, 'title' => mb_substr($title, 0, 255), 'description' => $description, - 'started_at' => $timestamp, + // webhook events are point events: the moment is known but the lead-up isn't, + // so started_at stays null and activity creation estimates the duration + 'started_at' => null, 'ended_at' => $timestamp, 'ticket_id' => $ticket?->id, 'ticket_number' => $ticket?->number, @@ -145,6 +159,75 @@ private function createEvent(User $user, string $eventTypeId, string $title, Car ]); } + /** + * A force-pushed commit may carry a rewritten committer date (e.g. after a `git rebase`), + * so on forced pushes we match by title alone; on normal pushes we still require the date + * to match, since recurring titles (e.g. "composer update") are otherwise legitimate new commits. + * + * @param array $commit + */ + private function isKnownCommit(array $commit, bool $forced): bool + { + $email = $this->extractEmail($commit['author']['raw'] ?? ''); + $date = $commit['date'] ?? null; + + if ($email === null || ! is_string($date)) { + return false; + } + + $user = User::where('email', $email)->first(); + + if ($user === null) { + return false; + } + + [$commitTitle] = $this->splitCommitMessage((string) ($commit['message'] ?? '')); + + $timestamp = $forced ? null : Carbon::parse($date)->utc(); + + return $this->eventExists($user, 'commit_pushed', $commitTitle, $timestamp); + } + + /** @param non-empty-list> $knownCommits */ + private function createRebaseEvent(array $knownCommits, ?string $branchName): void + { + $email = $this->extractEmail($knownCommits[0]['author']['raw'] ?? ''); + $user = $email === null ? null : User::where('email', $email)->first(); + + if ($user === null) { + return; + } + + $timestamp = collect($knownCommits) + ->map(fn (array $commit) => Carbon::parse($commit['date'])->utc()) + ->max(); + + $title = sprintf('Rebased %d commits on %s', count($knownCommits), $branchName ?? 'unknown branch'); + + if ($this->eventExists($user, 'rebase', $title, $timestamp)) { + return; + } + + $this->createEvent( + user: $user, + eventTypeId: 'rebase', + title: $title, + timestamp: $timestamp, + ticket: null, + ); + } + + private function eventExists(User $user, string $eventTypeId, string $title, ?Carbon $timestamp): bool + { + return Event::query() + ->where('user_id', $user->id) + ->where('source_id', ServiceProvider::SOURCE_ID) + ->where('event_type_id', $eventTypeId) + ->where('title', mb_substr($title, 0, 255)) + ->when($timestamp !== null, fn ($query) => $query->where('ended_at', $timestamp)) + ->exists(); + } + /** @return array{string, ?string} */ private function splitCommitMessage(string $message): array { diff --git a/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php b/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php new file mode 100644 index 0000000..7836f3b --- /dev/null +++ b/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php @@ -0,0 +1,168 @@ + $commits + * @return array + */ +function pushPayload(string $email, array $commits, bool $forced = false): array +{ + return [ + 'push' => [ + 'changes' => [[ + 'new' => ['name' => 'feature/test'], + 'forced' => $forced, + 'commits' => array_map(fn (array $commit) => [ + 'hash' => fake()->sha1(), + 'message' => $commit['message'], + 'date' => $commit['date'], + 'author' => ['raw' => 'Test User <'.$email.'>'], + ], $commits), + ]], + ], + 'repository' => ['full_name' => 'workspace/repo'], + ]; +} + +function pushMapping(): RepositoryMapping +{ + /** @var Customer $customer */ + $customer = Customer::factory()->create(); + + $integration = Integration::create(['name' => 'Bitbucket', 'type' => 'bitbucket', 'config' => []]); + + return RepositoryMapping::create([ + 'integration_id' => $integration->id, + 'workspace_slug' => 'workspace', + 'repository_slug' => 'repo', + 'repository_name' => 'repo', + 'customer_id' => $customer->id, + 'budget_id' => null, + ]); +} + +it('creates a commit_pushed event for a new commit', function () { + EventFacade::fake(); + User::factory()->create(['email' => 'dev@example.com']); + $mapping = pushMapping(); + $payload = pushPayload('dev@example.com', [ + ['message' => 'add vite', 'date' => '2026-06-05T09:38:30+00:00'], + ]); + + new ProcessWebhookJob($payload, $mapping, 'repo:push')->handle(app(TicketService::class)); + + expect(Event::where('event_type_id', 'commit_pushed')->count())->toBe(1) + ->and(Event::where('event_type_id', 'rebase')->count())->toBe(0); +}); + +it('stores a commit as a point event without a start time', function () { + EventFacade::fake(); + User::factory()->create(['email' => 'dev@example.com']); + $mapping = pushMapping(); + $payload = pushPayload('dev@example.com', [ + ['message' => 'add vite', 'date' => '2026-06-05T09:38:30+00:00'], + ]); + + new ProcessWebhookJob($payload, $mapping, 'repo:push')->handle(app(TicketService::class)); + + $event = Event::sole(); + expect($event->started_at)->toBeNull() + ->and($event->ended_at->toIso8601String())->toBe('2026-06-05T09:38:30+00:00'); +}); + +it('creates one rebase event instead of duplicate commit events for known commits', function () { + EventFacade::fake(); + User::factory()->create(['email' => 'dev@example.com']); + $mapping = pushMapping(); + $payload = pushPayload('dev@example.com', [ + ['message' => 'add vite', 'date' => '2026-06-05T09:38:30+00:00'], + ['message' => 'fix pest', 'date' => '2026-06-05T09:40:00+00:00'], + ]); + + new ProcessWebhookJob($payload, $mapping, 'repo:push')->handle(app(TicketService::class)); + new ProcessWebhookJob($payload, $mapping, 'repo:push')->handle(app(TicketService::class)); + + expect(Event::where('event_type_id', 'commit_pushed')->count())->toBe(2) + ->and(Event::where('event_type_id', 'rebase')->count())->toBe(1); +}); + +it('does not create a second rebase event when the same push is replayed again', function () { + EventFacade::fake(); + User::factory()->create(['email' => 'dev@example.com']); + $mapping = pushMapping(); + $payload = pushPayload('dev@example.com', [ + ['message' => 'add vite', 'date' => '2026-06-05T09:38:30+00:00'], + ]); + + new ProcessWebhookJob($payload, $mapping, 'repo:push')->handle(app(TicketService::class)); + new ProcessWebhookJob($payload, $mapping, 'repo:push')->handle(app(TicketService::class)); + new ProcessWebhookJob($payload, $mapping, 'repo:push')->handle(app(TicketService::class)); + + expect(Event::where('event_type_id', 'rebase')->count())->toBe(1); +}); + +it('creates events for new commits alongside a rebase event for known ones', function () { + EventFacade::fake(); + User::factory()->create(['email' => 'dev@example.com']); + $mapping = pushMapping(); + $firstPush = pushPayload('dev@example.com', [ + ['message' => 'add vite', 'date' => '2026-06-05T09:38:30+00:00'], + ]); + $secondPush = pushPayload('dev@example.com', [ + ['message' => 'add vite', 'date' => '2026-06-05T09:38:30+00:00'], + ['message' => 'fix phpstan', 'date' => '2026-06-05T10:00:00+00:00'], + ]); + + new ProcessWebhookJob($firstPush, $mapping, 'repo:push')->handle(app(TicketService::class)); + new ProcessWebhookJob($secondPush, $mapping, 'repo:push')->handle(app(TicketService::class)); + + expect(Event::where('event_type_id', 'commit_pushed')->count())->toBe(2) + ->and(Event::where('event_type_id', 'rebase')->count())->toBe(1); +}); + +it('recognizes a rebased commit on a force-pushed branch by title even when the date changed', function () { + EventFacade::fake(); + User::factory()->create(['email' => 'dev@example.com']); + $mapping = pushMapping(); + $originalPush = pushPayload('dev@example.com', [ + ['message' => 'add vite', 'date' => '2026-06-05T09:38:30+00:00'], + ]); + $forcedPush = pushPayload('dev@example.com', [ + ['message' => 'add vite', 'date' => '2026-06-05T11:00:00+00:00'], + ], forced: true); + + new ProcessWebhookJob($originalPush, $mapping, 'repo:push')->handle(app(TicketService::class)); + new ProcessWebhookJob($forcedPush, $mapping, 'repo:push')->handle(app(TicketService::class)); + + expect(Event::where('event_type_id', 'commit_pushed')->count())->toBe(1) + ->and(Event::where('event_type_id', 'rebase')->count())->toBe(1); +}); + +it('creates a new commit_pushed event for a recurring commit title with a different date on a normal push', function () { + EventFacade::fake(); + User::factory()->create(['email' => 'dev@example.com']); + $mapping = pushMapping(); + $firstPush = pushPayload('dev@example.com', [ + ['message' => 'composer update', 'date' => '2026-06-05T09:38:30+00:00'], + ]); + $secondPush = pushPayload('dev@example.com', [ + ['message' => 'composer update', 'date' => '2026-06-06T09:38:30+00:00'], + ]); + + new ProcessWebhookJob($firstPush, $mapping, 'repo:push')->handle(app(TicketService::class)); + new ProcessWebhookJob($secondPush, $mapping, 'repo:push')->handle(app(TicketService::class)); + + expect(Event::where('event_type_id', 'commit_pushed')->count())->toBe(2) + ->and(Event::where('event_type_id', 'rebase')->count())->toBe(0); +});