php-uptelligence/Services/WebhookReceiverService.php
Snider e0d2325a20 refactor: move namespace from Core\Uptelligence to Core\Mod\Uptelligence
Aligns module namespace with Core PHP Framework conventions where
modules live under the Core\Mod\ namespace hierarchy. This follows
the monorepo separation work started in 40d893a.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:32:55 +00:00

435 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Uptelligence\Services;
use Illuminate\Support\Facades\Log;
use Core\Mod\Uptelligence\Models\UptelligenceWebhook;
use Core\Mod\Uptelligence\Models\UptelligenceWebhookDelivery;
use Core\Mod\Uptelligence\Models\Vendor;
use Core\Mod\Uptelligence\Models\VersionRelease;
/**
* WebhookReceiverService - processes incoming vendor release webhooks.
*
* Handles webhook verification, payload parsing, and release record creation
* for GitHub releases, GitLab releases, npm publish, and Packagist webhooks.
*/
class WebhookReceiverService
{
// -------------------------------------------------------------------------
// Signature Verification
// -------------------------------------------------------------------------
/**
* Verify webhook signature.
*
* Returns signature status for logging.
*/
public function verifySignature(UptelligenceWebhook $webhook, string $payload, ?string $signature): string
{
if (empty($webhook->secret)) {
return UptelligenceWebhookDelivery::SIGNATURE_MISSING;
}
if (empty($signature)) {
return UptelligenceWebhookDelivery::SIGNATURE_MISSING;
}
$isValid = $webhook->verifySignature($payload, $signature);
return $isValid
? UptelligenceWebhookDelivery::SIGNATURE_VALID
: UptelligenceWebhookDelivery::SIGNATURE_INVALID;
}
// -------------------------------------------------------------------------
// Payload Parsing
// -------------------------------------------------------------------------
/**
* Parse payload based on provider.
*
* Returns normalised release data or null if not a release event.
*
* @return array{
* event_type: string,
* version: string|null,
* tag_name: string|null,
* release_name: string|null,
* body: string|null,
* url: string|null,
* prerelease: bool,
* draft: bool,
* published_at: string|null,
* author: string|null,
* raw: array
* }|null
*/
public function parsePayload(string $provider, array $payload): ?array
{
return match ($provider) {
UptelligenceWebhook::PROVIDER_GITHUB => $this->parseGitHubPayload($payload),
UptelligenceWebhook::PROVIDER_GITLAB => $this->parseGitLabPayload($payload),
UptelligenceWebhook::PROVIDER_NPM => $this->parseNpmPayload($payload),
UptelligenceWebhook::PROVIDER_PACKAGIST => $this->parsePackagistPayload($payload),
default => $this->parseCustomPayload($payload),
};
}
/**
* Parse GitHub release webhook payload.
*
* GitHub sends:
* - action: published, created, edited, deleted, prereleased, released
* - release: { tag_name, name, body, draft, prerelease, created_at, published_at, author }
*/
protected function parseGitHubPayload(array $payload): ?array
{
// Only process release events
$action = $payload['action'] ?? null;
if (! in_array($action, ['published', 'released', 'created'])) {
return null;
}
$release = $payload['release'] ?? [];
if (empty($release)) {
return null;
}
$tagName = $release['tag_name'] ?? null;
$version = $this->normaliseVersion($tagName);
return [
'event_type' => "github.release.{$action}",
'version' => $version,
'tag_name' => $tagName,
'release_name' => $release['name'] ?? $tagName,
'body' => $release['body'] ?? null,
'url' => $release['html_url'] ?? null,
'prerelease' => (bool) ($release['prerelease'] ?? false),
'draft' => (bool) ($release['draft'] ?? false),
'published_at' => $release['published_at'] ?? $release['created_at'] ?? null,
'author' => $release['author']['login'] ?? null,
'raw' => $release,
];
}
/**
* Parse GitLab release webhook payload.
*
* GitLab sends:
* - object_kind: release
* - action: create, update, delete
* - tag: tag name
* - name, description, released_at
*/
protected function parseGitLabPayload(array $payload): ?array
{
$objectKind = $payload['object_kind'] ?? null;
$action = $payload['action'] ?? null;
// Handle release events
if ($objectKind === 'release' && in_array($action, ['create', 'update'])) {
$tagName = $payload['tag'] ?? null;
$version = $this->normaliseVersion($tagName);
return [
'event_type' => "gitlab.release.{$action}",
'version' => $version,
'tag_name' => $tagName,
'release_name' => $payload['name'] ?? $tagName,
'body' => $payload['description'] ?? null,
'url' => $payload['url'] ?? null,
'prerelease' => false,
'draft' => false,
'published_at' => $payload['released_at'] ?? $payload['created_at'] ?? null,
'author' => null,
'raw' => $payload,
];
}
// Handle tag push events (may indicate release)
if ($objectKind === 'tag_push') {
$ref = $payload['ref'] ?? '';
$tagName = str_replace('refs/tags/', '', $ref);
$version = $this->normaliseVersion($tagName);
// Only process if it looks like a version tag
if ($version && $this->isVersionTag($tagName)) {
return [
'event_type' => 'gitlab.tag.push',
'version' => $version,
'tag_name' => $tagName,
'release_name' => $tagName,
'body' => null,
'url' => null,
'prerelease' => false,
'draft' => false,
'published_at' => null,
'author' => $payload['user_name'] ?? null,
'raw' => $payload,
];
}
}
return null;
}
/**
* Parse npm publish webhook payload.
*
* npm sends:
* - event: package:publish
* - name: package name
* - version: version number
* - dist-tags: { latest, next, etc. }
*/
protected function parseNpmPayload(array $payload): ?array
{
$event = $payload['event'] ?? null;
// Handle package publish events
if ($event !== 'package:publish') {
return null;
}
$version = $payload['version'] ?? null;
if (empty($version)) {
return null;
}
$distTags = $payload['dist-tags'] ?? [];
$isLatest = ($distTags['latest'] ?? null) === $version;
return [
'event_type' => 'npm.package.publish',
'version' => $version,
'tag_name' => $version,
'release_name' => ($payload['name'] ?? 'Package')." v{$version}",
'body' => null,
'url' => isset($payload['name']) ? "https://www.npmjs.com/package/{$payload['name']}/v/{$version}" : null,
'prerelease' => ! $isLatest,
'draft' => false,
'published_at' => $payload['time'] ?? null,
'author' => $payload['maintainers'][0]['name'] ?? null,
'raw' => $payload,
];
}
/**
* Parse Packagist webhook payload.
*
* Packagist sends:
* - package: { name, url }
* - versions: array of version objects
*/
protected function parsePackagistPayload(array $payload): ?array
{
$package = $payload['package'] ?? $payload['repository'] ?? [];
$versions = $payload['versions'] ?? [];
// Find the latest version
if (empty($versions)) {
return null;
}
// Get the most recent version (first in array or highest semver)
$latestVersion = null;
$latestVersionData = null;
foreach ($versions as $versionKey => $versionData) {
// Skip dev versions
if (str_contains($versionKey, 'dev-')) {
continue;
}
$normalised = $this->normaliseVersion($versionKey);
if ($normalised && (! $latestVersion || version_compare($normalised, $latestVersion, '>'))) {
$latestVersion = $normalised;
$latestVersionData = $versionData;
}
}
if (! $latestVersion) {
return null;
}
return [
'event_type' => 'packagist.package.update',
'version' => $latestVersion,
'tag_name' => $latestVersionData['version'] ?? $latestVersion,
'release_name' => ($package['name'] ?? 'Package')." {$latestVersion}",
'body' => $latestVersionData['description'] ?? null,
'url' => $package['url'] ?? null,
'prerelease' => false,
'draft' => false,
'published_at' => $latestVersionData['time'] ?? null,
'author' => $latestVersionData['authors'][0]['name'] ?? null,
'raw' => $payload,
];
}
/**
* Parse custom webhook payload.
*
* Accepts a flexible format for custom integrations.
*/
protected function parseCustomPayload(array $payload): ?array
{
// Try common field names for version
$version = $payload['version']
?? $payload['tag']
?? $payload['tag_name']
?? $payload['release']['version']
?? $payload['release']['tag_name']
?? null;
if (empty($version)) {
return null;
}
$normalised = $this->normaliseVersion($version);
return [
'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'custom.release',
'version' => $normalised ?? $version,
'tag_name' => $version,
'release_name' => $payload['name'] ?? $payload['release_name'] ?? $version,
'body' => $payload['body'] ?? $payload['description'] ?? $payload['changelog'] ?? null,
'url' => $payload['url'] ?? $payload['release_url'] ?? null,
'prerelease' => (bool) ($payload['prerelease'] ?? false),
'draft' => (bool) ($payload['draft'] ?? false),
'published_at' => $payload['published_at'] ?? $payload['released_at'] ?? $payload['timestamp'] ?? null,
'author' => $payload['author'] ?? null,
'raw' => $payload,
];
}
// -------------------------------------------------------------------------
// Release Processing
// -------------------------------------------------------------------------
/**
* Process a parsed release and create/update vendor version record.
*
* @return array{action: string, release_id: int|null, version: string|null}
*/
public function processRelease(
UptelligenceWebhookDelivery $delivery,
Vendor $vendor,
array $parsedData
): array {
$version = $parsedData['version'] ?? null;
if (empty($version)) {
return [
'action' => 'skipped',
'release_id' => null,
'version' => null,
'reason' => 'No version found in payload',
];
}
// Skip draft releases
if ($parsedData['draft'] ?? false) {
return [
'action' => 'skipped',
'release_id' => null,
'version' => $version,
'reason' => 'Draft release',
];
}
// Check if this version already exists
$existingRelease = VersionRelease::where('vendor_id', $vendor->id)
->where('version', $version)
->first();
if ($existingRelease) {
Log::info('Uptelligence webhook: Version already exists', [
'vendor_id' => $vendor->id,
'version' => $version,
'release_id' => $existingRelease->id,
]);
return [
'action' => 'exists',
'release_id' => $existingRelease->id,
'version' => $version,
];
}
// Create new version release record
$release = VersionRelease::create([
'vendor_id' => $vendor->id,
'version' => $version,
'previous_version' => $vendor->current_version,
'metadata_json' => [
'release_name' => $parsedData['release_name'] ?? null,
'body' => $parsedData['body'] ?? null,
'url' => $parsedData['url'] ?? null,
'prerelease' => $parsedData['prerelease'] ?? false,
'published_at' => $parsedData['published_at'] ?? null,
'author' => $parsedData['author'] ?? null,
'webhook_delivery_id' => $delivery->id,
'event_type' => $parsedData['event_type'] ?? null,
],
]);
// Update vendor's current version
$vendor->update([
'previous_version' => $vendor->current_version,
'current_version' => $version,
'last_checked_at' => now(),
]);
Log::info('Uptelligence webhook: New release recorded', [
'vendor_id' => $vendor->id,
'vendor_name' => $vendor->name,
'version' => $version,
'release_id' => $release->id,
]);
return [
'action' => 'created',
'release_id' => $release->id,
'version' => $version,
];
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Normalise a version string by removing common prefixes.
*/
protected function normaliseVersion(?string $version): ?string
{
if (empty($version)) {
return null;
}
// Remove common prefixes
$normalised = preg_replace('/^v(?:ersion)?[.\-]?/i', '', $version);
// Validate it looks like a version number
if (preg_match('/^\d+\.\d+/', $normalised)) {
return $normalised;
}
// If it doesn't look like a version, return as-is
return $version;
}
/**
* Check if a tag name looks like a version tag.
*/
protected function isVersionTag(string $tagName): bool
{
// Common version patterns
return (bool) preg_match('/^v?\d+\.\d+(\.\d+)?/', $tagName);
}
}