Files
WMS/Modules/Helpdesk/Services/GitWebhookIngestionService.php
T
2026-06-15 13:47:58 +01:00

243 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Log;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\GitWebhookLog;
use Modules\Helpdesk\Models\Ticket;
use Throwable;
final class GitWebhookIngestionService
{
private const TICKET_REFERENCE_PATTERN = '/\bHD-\d{4}-\d{5}\b/i';
/**
* Persist supported Git provider commits and link them to matching Helpdesk ticket references.
*
* @param array<string, mixed> $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<string, mixed>|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',
};
}
/**
* @param mixed $value
*/
private function branchFromRef(mixed $value): ?string
{
$ref = $this->stringOrNull($value);
if ($ref === null) {
return null;
}
return str_replace('refs/heads/', '', $ref);
}
/**
* @param array<string, mixed> $payload
* @return array<int, array<string, mixed>>
*/
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();
}
/**
* @param mixed $value
*/
private function parseTimestamp(mixed $value): ?CarbonImmutable
{
$timestamp = $this->stringOrNull($value);
if ($timestamp === null) {
return null;
}
try {
return CarbonImmutable::parse($timestamp);
} catch (Throwable) {
return null;
}
}
/**
* @param mixed $value
*/
private function stringOrNull(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$string = trim((string) $value);
return $string === '' ? null : $string;
}
}