From fae5abceb81c097e52a53a719f7a0ff36b59d351 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 26 Apr 2026 01:03:24 +0100 Subject: [PATCH] feat(agent/php): Phase 4 scheduler + Mantis webhook (#830 narrowed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- php/Console/Kernel.php | 21 +++ .../Api/MantisWebhookController.php | 92 +++++++++++++ php/Routes/api.php | 2 + .../Api/MantisWebhookControllerTest.php | 126 ++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 php/Console/Kernel.php create mode 100644 php/Http/Controllers/Api/MantisWebhookController.php create mode 100644 php/tests/Feature/Api/MantisWebhookControllerTest.php diff --git a/php/Console/Kernel.php b/php/Console/Kernel.php new file mode 100644 index 0000000..6c792a0 --- /dev/null +++ b/php/Console/Kernel.php @@ -0,0 +1,21 @@ +command('agentic:sync-profiles')->hourly(); + $schedule->command('agentic:dispatch-queue --limit=3')->everyFiveMinutes(); + } +} diff --git a/php/Http/Controllers/Api/MantisWebhookController.php b/php/Http/Controllers/Api/MantisWebhookController.php new file mode 100644 index 0000000..7377fcd --- /dev/null +++ b/php/Http/Controllers/Api/MantisWebhookController.php @@ -0,0 +1,92 @@ +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); + } +} diff --git a/php/Routes/api.php b/php/Routes/api.php index cdea33f..86c7eee 100644 --- a/php/Routes/api.php +++ b/php/Routes/api.php @@ -40,6 +40,8 @@ Route::get('v1/health', [AgentApiController::class, 'health']); Route::post('github/webhook', [\Core\Mod\Agentic\Controllers\Api\GitHubWebhookController::class, 'receive']) ->middleware('throttle:120,1'); +Route::post('agentic/mantis-webhook', [\Core\Mod\Agentic\Http\Controllers\Api\MantisWebhookController::class, 'receive']); + // Agent checkin — discover which repos changed since last sync // Uses auth.api (brain key) for authentication Route::middleware(['throttle:120,1', 'auth.api:brain:read'])->group(function () { diff --git a/php/tests/Feature/Api/MantisWebhookControllerTest.php b/php/tests/Feature/Api/MantisWebhookControllerTest.php new file mode 100644 index 0000000..a29cd9f --- /dev/null +++ b/php/tests/Feature/Api/MantisWebhookControllerTest.php @@ -0,0 +1,126 @@ + 'base64:'.base64_encode(random_bytes(32))]); + } + + config(['agentic.mantis.webhook_secret' => 'mantis-secret']); + + Route::prefix('api')->group(function (): void { + require __DIR__.'/../../../Routes/api.php'; + }); +}); + +function mantisWebhookProfile(array $overrides = []): AgentProfile +{ + static $sequence = 0; + + $sequence++; + + return AgentProfile::query()->create(array_merge([ + 'name' => "mantis-webhook-profile-{$sequence}", + 'gateway_url' => "https://gateway-{$sequence}.example.test", + 'api_key_cipher' => "secret-{$sequence}", + 'cost_class' => 'C', + 'capability_tags' => ['dispatch'], + 'quota_headroom_pct' => 100, + 'enabled' => true, + 'last_dispatched_at' => null, + ], $overrides)); +} + +test('MantisWebhookController_receive_Good_returns_200_and_dispatches_issue_opened', function (): void { + Queue::fake(); + + mantisWebhookProfile(); + + $response = $this + ->withHeader('X-Mantis-Webhook-Secret', 'mantis-secret') + ->postJson('/api/agentic/mantis-webhook', [ + 'event' => 'issue.opened', + 'issue' => [ + 'id' => 123, + 'summary' => 'Investigate the regression', + 'severity' => 'minor', + 'priority' => 'normal', + ], + ]); + + $response + ->assertOk() + ->assertJsonPath('status', 'accepted') + ->assertJsonPath('dispatched', true); + + Queue::assertPushedOn('ai', DispatchMantisTicketJob::class); + Queue::assertPushed(DispatchMantisTicketJob::class, function (DispatchMantisTicketJob $job): bool { + return $job->ticketId === 123; + }); +}); + +test('MantisWebhookController_receive_Bad_returns_401_for_a_wrong_secret', function (): void { + Queue::fake(); + + $response = $this + ->withHeader('X-Mantis-Webhook-Secret', 'wrong-secret') + ->postJson('/api/agentic/mantis-webhook', [ + 'event' => 'issue.opened', + 'issue' => [ + 'id' => 123, + ], + ]); + + $response + ->assertUnauthorized() + ->assertJsonPath('message', 'Unauthorised'); + + Queue::assertNothingPushed(); +}); + +test('MantisWebhookController_receive_Good_returns_204_for_an_unhandled_event', function (): void { + Queue::fake(); + + mantisWebhookProfile(); + + $response = $this + ->withHeader('X-Mantis-Webhook-Secret', 'mantis-secret') + ->postJson('/api/agentic/mantis-webhook', [ + 'event' => 'issue.closed', + 'issue' => [ + 'id' => 123, + 'summary' => 'Closed by maintainer', + ], + ]); + + $response->assertNoContent(); + + Queue::assertNothingPushed(); +}); + +test('MantisWebhookController_receive_Ugly_returns_422_for_a_malformed_body', function (): void { + Queue::fake(); + + $response = $this + ->withHeader('X-Mantis-Webhook-Secret', 'mantis-secret') + ->postJson('/api/agentic/mantis-webhook', [ + 'event' => 'issue.opened', + 'issue' => [ + 'summary' => 'Missing identifier', + ], + ]); + + $response + ->assertUnprocessable() + ->assertJsonValidationErrors(['issue.id']); + + Queue::assertNothingPushed(); +});