84 lines
4.6 KiB
PHP
84 lines
4.6 KiB
PHP
<?php
|
|
|
|
namespace App\Mcp\Tools;
|
|
|
|
use App\Actions\Permissions\PermissionService;
|
|
use App\Mcp\Support\KanbanGitMcp;
|
|
use App\Models\Ticket;
|
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\JsonSchema\Types\Type;
|
|
use Illuminate\Support\Arr;
|
|
use Laravel\Mcp\Request;
|
|
use Laravel\Mcp\Response;
|
|
use Laravel\Mcp\ResponseFactory;
|
|
use Laravel\Mcp\Server\Attributes\Description;
|
|
use Laravel\Mcp\Server\Attributes\Name;
|
|
use Laravel\Mcp\Server\Tool;
|
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
|
|
|
#[Name('search_tickets')]
|
|
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
|
|
#[IsReadOnly]
|
|
class SearchTickets extends Tool
|
|
{
|
|
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
|
|
|
public function handle(Request $request): Response|ResponseFactory
|
|
{
|
|
$input = $request->validate([
|
|
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
|
'query' => ['sometimes', 'string', 'max:255'],
|
|
'project_id' => ['sometimes', 'integer'],
|
|
'ticket_status_id' => ['sometimes', 'integer'],
|
|
'ticket_type_id' => ['sometimes', 'integer'],
|
|
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
|
|
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
|
|
'page' => ['sometimes', 'integer', 'min:1'],
|
|
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
|
]);
|
|
$user = $this->kanbanGitMcp->user($request);
|
|
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
|
|
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
|
|
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
|
|
if (Arr::exists($input, $filter)) {
|
|
$query->where($filter, $input[$filter]);
|
|
}
|
|
}
|
|
if (Arr::exists($input, 'query')) {
|
|
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
|
|
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
|
|
}
|
|
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
|
|
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
|
|
|
|
return Response::structured([
|
|
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
|
|
'id' => $ticket->id,
|
|
'ticket_key' => $ticket->ticket_key,
|
|
'title' => $ticket->title,
|
|
'project_id' => $ticket->project_id,
|
|
'ticket_status_id' => $ticket->ticket_status_id,
|
|
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
|
|
])->all(),
|
|
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
|
|
]);
|
|
}
|
|
|
|
/** @return array<string, Type> */
|
|
public function schema(JsonSchema $schema): array
|
|
{
|
|
return [
|
|
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
|
|
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
|
|
'project_id' => $schema->integer()->description('Visible project ID.'),
|
|
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
|
|
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
|
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
|
|
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
|
|
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
|
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
|
];
|
|
}
|
|
}
|