From 50c590a109e6978dc4bbc34287eb330906a5a429 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 10:12:52 +0200 Subject: [PATCH 1/3] feat: detect rebased commits in bitbucket pushes instead of duplicating events --- ...26_07_16_000001_seed_rebase_event_type.php | 19 +++ .../bitbucket/src/Jobs/ProcessWebhookJob.php | 72 +++++++++++ .../Bitbucket/ProcessPushWebhookJobTest.php | 116 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 integrations/bitbucket/database/migrations/2026_07_16_000001_seed_rebase_event_type.php create mode 100644 tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php 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..4a07c09 100644 --- a/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php +++ b/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php @@ -49,9 +49,20 @@ private function handlePush(TicketService $ticketService): void foreach ($this->payload['push']['changes'] ?? [] as $change) { $branchName = is_string($change['new']['name'] ?? null) ? $change['new']['name'] : null; + $knownCommits = []; foreach ($change['commits'] ?? [] as $commit) { + if ($this->isKnownCommit($commit)) { + $knownCommits[] = $commit; + + continue; + } + $this->createEventFromCommit($commit, $branchName, $ticketService); } + + if ($knownCommits !== []) { + $this->createRebaseEvent($knownCommits, $branchName); + } } } @@ -145,6 +156,67 @@ private function createEvent(User $user, string $eventTypeId, string $title, Car ]); } + /** @param array $commit */ + private function isKnownCommit(array $commit): 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'] ?? '')); + + return $this->eventExists($user, 'commit_pushed', $commitTitle, Carbon::parse($date)->utc()); + } + + /** @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('started_at', $timestamp) + ->where('title', mb_substr($title, 0, 255)) + ->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..ed9f7fb --- /dev/null +++ b/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php @@ -0,0 +1,116 @@ + $commits + * @return array + */ +function pushPayload(string $email, array $commits): array +{ + return [ + 'push' => [ + 'changes' => [[ + 'new' => ['name' => 'feature/test'], + '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('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); +}); From d535d88fb887649516ba4bf2e849d29d326ea46c Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 11:22:53 +0200 Subject: [PATCH 2/3] fix: detect rebased commits by title on force-pushed branches Bitbucket's commit.date in push webhooks is the committer date, which git rebase rewrites. isKnownCommit() matched on title+date, so rebased commits were missed as new instead of recognized as replays. Use changes[].forced to distinguish: on forced pushes, match known commits by user+source+commit_pushed+title only (date is unreliable); on normal pushes, keep the existing title+date match so legitimate recurring titles (e.g. "composer update") still create new events. --- .../bitbucket/src/Jobs/ProcessWebhookJob.php | 21 +++++++--- .../Bitbucket/ProcessPushWebhookJobTest.php | 39 ++++++++++++++++++- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php b/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php index 4a07c09..0d61b3e 100644 --- a/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php +++ b/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php @@ -48,10 +48,11 @@ 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)) { + if ($this->isKnownCommit($commit, $forced)) { $knownCommits[] = $commit; continue; @@ -156,8 +157,14 @@ private function createEvent(User $user, string $eventTypeId, string $title, Car ]); } - /** @param array $commit */ - private function isKnownCommit(array $commit): bool + /** + * 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; @@ -174,7 +181,9 @@ private function isKnownCommit(array $commit): bool [$commitTitle] = $this->splitCommitMessage((string) ($commit['message'] ?? '')); - return $this->eventExists($user, 'commit_pushed', $commitTitle, Carbon::parse($date)->utc()); + $timestamp = $forced ? null : Carbon::parse($date)->utc(); + + return $this->eventExists($user, 'commit_pushed', $commitTitle, $timestamp); } /** @param non-empty-list> $knownCommits */ @@ -206,14 +215,14 @@ private function createRebaseEvent(array $knownCommits, ?string $branchName): vo ); } - private function eventExists(User $user, string $eventTypeId, string $title, Carbon $timestamp): bool + 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('started_at', $timestamp) ->where('title', mb_substr($title, 0, 255)) + ->when($timestamp !== null, fn ($query) => $query->where('started_at', $timestamp)) ->exists(); } diff --git a/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php b/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php index ed9f7fb..b9e9f05 100644 --- a/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php +++ b/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php @@ -16,12 +16,13 @@ * @param list $commits * @return array */ -function pushPayload(string $email, array $commits): 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'], @@ -114,3 +115,39 @@ function pushMapping(): RepositoryMapping 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); +}); From 06b258f81c17a6f0c624041e5823a9ef84bc0cae Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 12:00:04 +0200 Subject: [PATCH 3/3] fix: store bitbucket events without start time so activities get an estimated duration --- .../bitbucket/src/Jobs/ProcessWebhookJob.php | 6 ++++-- .../Bitbucket/ProcessPushWebhookJobTest.php | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php b/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php index 0d61b3e..6f35b4e 100644 --- a/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php +++ b/integrations/bitbucket/src/Jobs/ProcessWebhookJob.php @@ -149,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, @@ -222,7 +224,7 @@ private function eventExists(User $user, string $eventTypeId, string $title, ?Ca ->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('started_at', $timestamp)) + ->when($timestamp !== null, fn ($query) => $query->where('ended_at', $timestamp)) ->exists(); } diff --git a/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php b/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php index b9e9f05..7836f3b 100644 --- a/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php +++ b/tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php @@ -66,6 +66,21 @@ function pushMapping(): RepositoryMapping ->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']);