agent/php/Http/Controllers/Api/MantisWebhookController.php
Snider fae5abceb8 feat(agent/php): Phase 4 scheduler + Mantis webhook (#830 narrowed)
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>
2026-04-26 01:03:31 +01:00

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);
}
}