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>
This commit is contained in:
parent
82ffd420e0
commit
fae5abceb8
4 changed files with 241 additions and 0 deletions
21
php/Console/Kernel.php
Normal file
21
php/Console/Kernel.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Console;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
parent::schedule($schedule);
|
||||
|
||||
$schedule->command('agentic:sync-profiles')->hourly();
|
||||
$schedule->command('agentic:dispatch-queue --limit=3')->everyFiveMinutes();
|
||||
}
|
||||
}
|
||||
92
php/Http/Controllers/Api/MantisWebhookController.php
Normal file
92
php/Http/Controllers/Api/MantisWebhookController.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
126
php/tests/Feature/Api/MantisWebhookControllerTest.php
Normal file
126
php/tests/Feature/Api/MantisWebhookControllerTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Jobs\DispatchMantisTicketJob;
|
||||
use Core\Mod\Agentic\Models\AgentProfile;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
beforeEach(function (): void {
|
||||
if (! is_string(config('app.key')) || config('app.key') === '') {
|
||||
config(['app.key' => '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();
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue