Console\Kernel registers agentic:sync-profiles hourly + agentic:dispatch-queue --limit=3 every 5 minutes. Api\MantisWebhookController accepts POST /api/agentic/mantis-webhook, authenticates via X-Mantis-Webhook-Secret header (config-driven), validates payload, dispatches DispatchMantisTicketJob immediately for issue.opened (when ProfileSelector finds a profile), 204 for issue.closed/other events, 401 wrong secret, 422 malformed body. Pest Feature test covers all four cases (200 + dispatch, 401, 204, 422). Codex note: php -l clean; pest skipped (no vendor/). OUT OF SCOPE for this narrowed lane: OpenBrain memory writes + Langfuse trace observability (track separately as #830 follow-up). Closes tasks.lthn.sh/view.php?id=830 (narrowed — observability is followup) Co-authored-by: Codex <noreply@openai.com>
92 lines
2.9 KiB
PHP
92 lines
2.9 KiB
PHP
<?php
|
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Mod\Agentic\Http\Controllers\Api;
|
|
|
|
use Core\Mod\Agentic\Jobs\DispatchMantisTicketJob;
|
|
use Core\Mod\Agentic\Models\AgentProfile;
|
|
use Core\Mod\Agentic\Services\ProfileSelector;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Routing\Controller;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
class MantisWebhookController extends Controller
|
|
{
|
|
public function receive(Request $request, ProfileSelector $profileSelector): JsonResponse|Response
|
|
{
|
|
if (! $this->authorised($request)) {
|
|
Log::warning('Mantis webhook authentication failed', [
|
|
'event' => $request->input('event'),
|
|
'issue_id' => $request->input('issue.id'),
|
|
]);
|
|
|
|
return response()->json([
|
|
'message' => 'Unauthorised',
|
|
], 401);
|
|
}
|
|
|
|
/** @var array{event: string, issue: array{id: int|string, summary?: string, severity?: string, priority?: string}} $payload */
|
|
$payload = Validator::make($request->all(), [
|
|
'event' => ['required', 'string'],
|
|
'issue' => ['required', 'array'],
|
|
'issue.id' => ['required', 'integer'],
|
|
'issue.summary' => ['sometimes', 'string'],
|
|
'issue.severity' => ['sometimes', 'string'],
|
|
'issue.priority' => ['sometimes', 'string'],
|
|
])->validate();
|
|
|
|
$issueId = (int) $payload['issue']['id'];
|
|
|
|
if ($payload['event'] !== 'issue.opened') {
|
|
Log::info('Mantis webhook ignored event', [
|
|
'event' => $payload['event'],
|
|
'issue_id' => $issueId,
|
|
]);
|
|
|
|
return response()->noContent();
|
|
}
|
|
|
|
$profile = $profileSelector->pickFor($payload['issue']);
|
|
|
|
if (! $profile instanceof AgentProfile) {
|
|
Log::info('Mantis webhook found no matching profile for issue', [
|
|
'issue_id' => $issueId,
|
|
]);
|
|
|
|
return response()->json([
|
|
'status' => 'accepted',
|
|
'dispatched' => false,
|
|
]);
|
|
}
|
|
|
|
DispatchMantisTicketJob::dispatch($issueId);
|
|
|
|
Log::info('Mantis webhook dispatched issue', [
|
|
'issue_id' => $issueId,
|
|
'profile_id' => $profile->getKey(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'status' => 'accepted',
|
|
'dispatched' => true,
|
|
]);
|
|
}
|
|
|
|
private function authorised(Request $request): bool
|
|
{
|
|
$expectedSecret = (string) config('agentic.mantis.webhook_secret');
|
|
$providedSecret = (string) $request->header('X-Mantis-Webhook-Secret');
|
|
|
|
if ($expectedSecret === '' || $providedSecret === '') {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals($expectedSecret, $providedSecret);
|
|
}
|
|
}
|