Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

use App\Models\EventType;
use Illuminate\Database\Migrations\Migration;

return new class extends Migration
{
public function up(): void
{
$eventType = EventType::firstOrNew(['id' => 'rebase']);
$eventType->weight = 10;
$eventType->save();
}

public function down(): void
{
EventType::where('id', 'rebase')->delete();
}
};
85 changes: 84 additions & 1 deletion integrations/bitbucket/src/Jobs/ProcessWebhookJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -137,14 +149,85 @@ 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,
'ticket_type' => $ticket?->type,
]);
}

/**
* 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<string, mixed> $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<array<string, mixed>> $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
{
Expand Down
168 changes: 168 additions & 0 deletions tests/Integration/Bitbucket/ProcessPushWebhookJobTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php

use App\Integrations\TicketService;
use App\Models\Customer;
use App\Models\Event;
use App\Models\Integration;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event as EventFacade;
use Timatic\Bitbucket\Jobs\ProcessWebhookJob;
use Timatic\Bitbucket\Models\RepositoryMapping;

uses(RefreshDatabase::class);

/**
* @param list<array{message: string, date: string}> $commits
* @return array<string, mixed>
*/
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);
});
Loading