$payload * @return array{status: string, message: string, commit_count: int, linked_count: int, ignored_count: int} */ public function ingest(GitRepository $repository, array $payload, string $provider, ?string $deliveryId, ?string $event): array { if (! $this->isPushEvent($provider, $event)) { $result = [ 'status' => 'ignored', 'message' => sprintf('Ignored unsupported %s event.', $event ?: 'unknown'), 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1, ]; $this->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload); return $result; } $branch = $this->branchFromPayload($payload); $commits = $this->commitsFromPayload($payload); $commitCount = 0; $linkedCount = 0; $ignoredCount = 0; foreach ($commits as $commit) { $hash = $this->commitHash($commit); if ($hash === null) { $ignoredCount++; continue; } $message = $this->stringOrNull($commit['message'] ?? $commit['title'] ?? $commit['summary'] ?? null) ?? ''; $url = $this->commitUrl($commit); $author = is_array($commit['author'] ?? null) ? $commit['author'] : []; $committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : []; $authorName = $this->stringOrNull($author['name'] ?? $author['username'] ?? $committer['name'] ?? null); $authorEmail = $this->stringOrNull($author['email'] ?? $author['raw'] ?? $committer['email'] ?? null); $committedAt = $this->parseTimestamp( $commit['timestamp'] ?? $commit['committed_at'] ?? $commit['date'] ?? Arr::get($commit, 'author.date') ?? null, ); $ticket = $this->ticketForCommit($repository, $message, $branch, $url); GitCommit::query()->updateOrCreate( [ 'repository_id' => $repository->getKey(), 'hash' => $hash, ], [ 'ticket_id' => $ticket?->getKey(), 'short_hash' => substr($hash, 0, 12), 'branch' => $branch, 'author_name' => $authorName, 'author_email' => $authorEmail, 'message' => $message, 'url' => $url, 'committed_at' => $committedAt, ], ); $commitCount++; if ($ticket !== null) { $linkedCount++; } } $result = [ 'status' => 'accepted', 'message' => sprintf('Processed %d commit(s), linked %d to tickets.', $commitCount, $linkedCount), 'commit_count' => $commitCount, 'linked_count' => $linkedCount, 'ignored_count' => $ignoredCount, ]; $this->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload); return $result; } /** * Record a provider webhook acceptance, rejection or ignore decision for auditability. * * @param array{status: string, message: string, commit_count?: int, linked_count?: int, ignored_count?: int} $result * @param array|null $payload */ public function logWebhook(GitRepository $repository, string $provider, ?string $deliveryId, ?string $event, array $result, ?array $payload = null): void { try { GitWebhookLog::query()->create([ 'repository_id' => $repository->getKey(), 'provider' => $provider, 'delivery_id' => $deliveryId, 'event' => $event, 'status' => $result['status'], 'message' => $result['message'], 'commit_count' => (int) ($result['commit_count'] ?? 0), 'linked_count' => (int) ($result['linked_count'] ?? 0), 'ignored_count' => (int) ($result['ignored_count'] ?? 0), 'payload' => $payload, 'received_at' => now(), ]); } catch (Throwable $exception) { Log::warning('Helpdesk Git webhook audit log could not be written.', [ 'repository_id' => $repository->getKey(), 'provider' => $provider, 'event' => $event, 'status' => $result['status'], 'error' => $exception->getMessage(), ]); } } /** * Extract and normalise the first Helpdesk ticket reference from arbitrary Git text. */ public function extractTicketReference(string $value): ?string { if (! preg_match(self::TICKET_REFERENCE_PATTERN, $value, $matches)) { return null; } return sprintf('HD-%s-%05d', $matches[1], (int) $matches[2]); } /** * Determine whether the provider event should be processed as a push payload. */ private function isPushEvent(string $provider, ?string $event): bool { $normalisedProvider = Str::lower(trim($provider)); $normalisedEvent = Str::of((string) $event)->lower()->replace(['_', '-'], ' ')->squish()->value(); return match ($normalisedProvider) { 'github' => in_array($normalisedEvent, ['push', ''], true), 'gitlab' => in_array($normalisedEvent, ['push hook', 'push', ''], true), 'gitea' => in_array($normalisedEvent, ['push', 'push hook', ''], true), 'bitbucket' => in_array($normalisedEvent, ['repo push', 'push', ''], true), default => in_array($normalisedEvent, ['push', 'repo push', 'push hook', ''], true), }; } /** * Read the branch name from common Git provider payload shapes. * * @param array $payload */ private function branchFromPayload(array $payload): ?string { $ref = $this->stringOrNull( $payload['ref'] ?? Arr::get($payload, 'push.changes.0.new.name') ?? Arr::get($payload, 'object_attributes.ref') ?? null, ); if ($ref === null) { return null; } return Str::of($ref) ->replaceStart('refs/heads/', '') ->replaceStart('refs/tags/', '') ->trim() ->value(); } /** * @param array $payload * @return array> */ private function commitsFromPayload(array $payload): array { $commits = $payload['commits'] ?? null; if (is_array($commits) && $commits !== []) { return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit))); } $headCommit = $payload['head_commit'] ?? null; if (is_array($headCommit)) { return [$headCommit]; } $bitbucketCommits = Arr::get($payload, 'push.changes.0.commits', []); if (is_array($bitbucketCommits)) { return array_values(array_filter($bitbucketCommits, static fn (mixed $commit): bool => is_array($commit))); } return []; } /** * @param array $commit */ private function commitHash(array $commit): ?string { return $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? $commit['hash'] ?? null); } /** * @param array $commit */ private function commitUrl(array $commit): ?string { return $this->stringOrNull( $commit['url'] ?? $commit['web_url'] ?? Arr::get($commit, 'links.html.href') ?? null, ); } private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket { $reference = $this->extractTicketReference(trim($message.' '.($branch ?? '').' '.($url ?? ''))); if ($reference === null) { return null; } $baseQuery = Ticket::query()->where('reference', $reference); $strictProjectTicket = (clone $baseQuery) ->when($repository->project_id !== null, static function (Builder $query) use ($repository): void { $query->where('project_id', $repository->project_id); }) ->first(); return $strictProjectTicket ?? $baseQuery->first(); } private function parseTimestamp(mixed $value): ?CarbonImmutable { $timestamp = $this->stringOrNull($value); if ($timestamp === null) { return null; } try { return CarbonImmutable::parse($timestamp); } catch (Throwable) { return null; } } private function stringOrNull(mixed $value): ?string { if (! is_scalar($value)) { return null; } $string = trim((string) $value); return $string === '' ? null : $string; } }