175 lines
9.6 KiB
PHP
175 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace App\Mcp\Tools;
|
|
|
|
use App\Actions\Permissions\PermissionService;
|
|
use App\Actions\Tickets\CreateProjectTicket;
|
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
|
use App\Enums\Permission;
|
|
use App\Mcp\Support\DescribesTicketWriting;
|
|
use App\Mcp\Support\KanbanGitMcp;
|
|
use App\Models\Project;
|
|
use App\Models\Ticket;
|
|
use App\Models\User;
|
|
use App\Rules\VisualTicketDescription;
|
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
|
use Illuminate\JsonSchema\Types\Type;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
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\IsDestructive;
|
|
|
|
#[Name('create_ticket')]
|
|
#[Description('Create a ticket with all editable ticket fields, including its type, initial status, priority, assignee, author, description, story points, acceptance criteria, blocked state, and custom field values. Set parent_ticket_id to create a child of an existing ticket in the same project whose type allows children. Requires project write permission; overriding the author requires project admin permission. Ticket IDs, keys, sequence numbers, timestamps, and worklog entries are generated automatically.')]
|
|
#[IsDestructive(false)]
|
|
class CreateTicket extends Tool
|
|
{
|
|
use DescribesTicketWriting;
|
|
|
|
public function __construct(
|
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
|
private readonly CreateProjectTicket $createProjectTicket,
|
|
private readonly PermissionService $permissionService,
|
|
) {}
|
|
|
|
public function handle(Request $request): Response|ResponseFactory
|
|
{
|
|
$projectInput = $request->validate([
|
|
'project_id' => ['required', 'integer'],
|
|
]);
|
|
|
|
$user = $this->kanbanGitMcp->user($request);
|
|
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
|
$validated = $request->validate([
|
|
'title' => ['required', 'string', 'max:255'],
|
|
'description' => ['nullable', 'string', new VisualTicketDescription],
|
|
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
|
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
|
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
|
'parent_ticket_id' => ['nullable', 'integer', Rule::exists('tickets', 'id')->where('project_id', $project->id)],
|
|
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
|
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
|
'is_blocked' => ['sometimes', 'boolean'],
|
|
'blocked_comment' => ['nullable', 'string'],
|
|
'acceptance_criteria' => ['nullable', 'array', 'list'],
|
|
'acceptance_criteria.*' => ['array:text,checked,indent'],
|
|
'acceptance_criteria.*.text' => ['required', 'string'],
|
|
'acceptance_criteria.*.checked' => ['sometimes', 'boolean'],
|
|
'acceptance_criteria.*.indent' => ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL],
|
|
'field_values' => ['sometimes', 'array', 'list'],
|
|
'field_values.*' => ['array:ticket_field_definition_id,value_number,value_user_id'],
|
|
'field_values.*.ticket_field_definition_id' => [
|
|
'required', 'integer', 'distinct',
|
|
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
|
],
|
|
'field_values.*.value_number' => ['nullable', 'numeric'],
|
|
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
|
]);
|
|
|
|
$this->validateTicketConstraints($project, $user, $validated);
|
|
|
|
$ticket = $this->createProjectTicket->handle($project, $validated, $user);
|
|
|
|
return Response::structured([
|
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
|
'meta' => [
|
|
'message' => "{$ticket->ticket_key} was created.",
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
*/
|
|
private function validateTicketConstraints(Project $project, User $user, array $validated): void
|
|
{
|
|
$errors = [];
|
|
$parentTicketId = Arr::get($validated, 'parent_ticket_id');
|
|
|
|
if ($parentTicketId !== null) {
|
|
$parentTicket = Ticket::query()
|
|
->where('project_id', $project->id)
|
|
->with('ticketType')
|
|
->find($parentTicketId);
|
|
|
|
if (! $parentTicket?->ticketType?->is_parent) {
|
|
$errors['parent_ticket_id'] = 'Only tickets with a parent-enabled type can be selected as a parent.';
|
|
}
|
|
}
|
|
|
|
if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) {
|
|
$errors['blocked_comment'] = 'Blocked tickets require a blocker comment.';
|
|
}
|
|
|
|
if (
|
|
Arr::exists($validated, 'author_user_id')
|
|
&& (int) Arr::get($validated, 'author_user_id') !== $user->id
|
|
&& ! $this->permissionService->allows($user, Permission::ProjectAdmin, $project)
|
|
) {
|
|
$errors['author_user_id'] = 'Only project admins can change the author.';
|
|
}
|
|
|
|
$fieldValues = Arr::get($validated, 'field_values', []);
|
|
|
|
if ($fieldValues !== []) {
|
|
$fieldDefinitions = $project->ticketFieldDefinitions()->get()->keyBy('id');
|
|
|
|
foreach ($fieldValues as $index => $fieldValue) {
|
|
$fieldDefinition = $fieldDefinitions->get((int) Arr::get($fieldValue, 'ticket_field_definition_id'));
|
|
$incompatibleValue = $fieldDefinition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
|
|
|
if (Arr::get($fieldValue, $incompatibleValue) !== null) {
|
|
$errors["field_values.{$index}.{$incompatibleValue}"] = 'The value must match the custom field type.';
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
throw ValidationException::withMessages($errors);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string, Type>
|
|
*/
|
|
public function schema(JsonSchema $schema): array
|
|
{
|
|
return [
|
|
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
|
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
|
'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()),
|
|
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
|
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
|
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
|
'parent_ticket_id' => $schema->integer()->nullable()->description('Existing parent ticket ID in the same project. Its type must allow children. Omit or pass null for a top-level ticket.'),
|
|
'assignee_user_id' => $schema->integer()->nullable()->description('User ID to assign. Omit or pass null to leave unassigned.'),
|
|
'author_user_id' => $schema->integer()->nullable()->description('Defaults to the application key owner when omitted. Only project admins may choose another author or pass null for no author.'),
|
|
'story_points' => $schema->integer()->min(0)->nullable()->description('Non-negative story point estimate.'),
|
|
'is_blocked' => $schema->boolean()->default(false)->description('Whether the ticket is blocked. Requires a nonblank blocked_comment when true.'),
|
|
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason. Stored only when is_blocked is true.'),
|
|
'acceptance_criteria' => $schema->array()->nullable()
|
|
->items($schema->object([
|
|
'text' => $schema->string()->description('Checklist item text.')->required(),
|
|
'checked' => $schema->boolean()->default(false),
|
|
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
|
])->withoutAdditionalProperties())
|
|
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
|
|
'field_values' => $schema->array()
|
|
->items($schema->object([
|
|
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
|
'value_number' => $schema->number()->nullable()->description('Value for a numeric field.'),
|
|
'value_user_id' => $schema->integer()->nullable()->description('User ID for a user field.'),
|
|
])->withoutAdditionalProperties())
|
|
->description('Custom field values. Supply the value matching each definition type; omit or pass null for an empty value.'),
|
|
];
|
|
}
|
|
}
|