$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->branchFromRef($payload['ref'] ?? null); $commits = $this->commitsFromPayload($payload); $commitCount = 0; $linkedCount = 0; $ignoredCount = 0; foreach ($commits as $commit) { $hash = $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? null); if ($hash === null) { $ignoredCount++; continue; } $message = $this->stringOrNull($commit['message'] ?? null) ?? ''; $url = $this->stringOrNull($commit['url'] ?? null); $author = is_array($commit['author'] ?? null) ? $commit['author'] : []; $committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : []; $authorName = $this->stringOrNull($author['name'] ?? $committer['name'] ?? null); $authorEmail = $this->stringOrNull($author['email'] ?? $committer['email'] ?? null); $committedAt = $this->parseTimestamp($commit['timestamp'] ?? $commit['committed_at'] ?? 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 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 strtoupper($matches[0]); } /** * Determine whether the provider event should be processed as a push payload. */ private function isPushEvent(string $provider, ?string $event): bool { $normalised = strtolower((string) $event); return match ($provider) { 'github' => $normalised === 'push', 'gitlab' => in_array($normalised, ['push hook', 'push'], true), default => $normalised === '' || $normalised === 'push', }; } private function branchFromRef(mixed $value): ?string { $ref = $this->stringOrNull($value); if ($ref === null) { return null; } return str_replace('refs/heads/', '', $ref); } /** * @param array $payload * @return array> */ private function commitsFromPayload(array $payload): array { $commits = $payload['commits'] ?? []; if (! is_array($commits)) { return []; } return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit))); } private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket { $reference = $this->extractTicketReference(trim($message.' '.($branch ?? '').' '.($url ?? ''))); if ($reference === null) { return null; } $query = Ticket::query()->where('reference', $reference); if ($repository->company_id !== null) { $query->where('company_id', $repository->company_id); } if ($repository->project_id !== null) { $query->where('project_id', $repository->project_id); } return $query->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; } }