300 lines
9.9 KiB
PHP
Executable File
300 lines
9.9 KiB
PHP
Executable File
<?php
|
|
|
|
/**
|
|
* GitWebhookIngestionService.php
|
|
*
|
|
* Normalises Git provider webhooks and links commits to Helpdesk tickets.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\Helpdesk\Services;
|
|
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
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
|
|
{
|
|
/**
|
|
* Matches the generated Helpdesk reference format, for example HD-2026-00042.
|
|
*/
|
|
private const TICKET_REFERENCE_PATTERN = '/\bHD[-\s_]?(\d{4})[-\s_]?(\d{1,8})\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->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<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 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<string, mixed> $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<string, mixed> $payload
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $commit
|
|
*/
|
|
private function commitHash(array $commit): ?string
|
|
{
|
|
return $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? $commit['hash'] ?? null);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $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;
|
|
}
|
|
}
|