Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed8d0c6cc | ||
|
|
ba12e65e81 | ||
|
|
85b9409e3c | ||
|
|
f862a6a226 |
@@ -14,6 +14,7 @@ class DeleteProject
|
||||
DB::transaction(function () use ($project): void {
|
||||
$baseTemplate = $project->baseTemplate()->first();
|
||||
|
||||
$project->ticketStatuses()->delete();
|
||||
$project->delete();
|
||||
$baseTemplate?->delete();
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
|
||||
/**
|
||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||
*/
|
||||
public static function syncTicket(Ticket $ticket, array $items): void
|
||||
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
|
||||
{
|
||||
$normalized = self::normalize($items);
|
||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
|
||||
: $existingCriteria->get($criterionId);
|
||||
|
||||
while (
|
||||
$criterion === null
|
||||
$reuseUnidentifiedCriteria
|
||||
&& $criterion === null
|
||||
&& $fallbackIndex < $fallbackCriteria->count()
|
||||
) {
|
||||
/** @var AcceptanceCriterion $candidate */
|
||||
|
||||
@@ -51,6 +51,7 @@ class UpdateProjectTicket
|
||||
Ticket $ticket,
|
||||
User $actor,
|
||||
array $validated,
|
||||
bool $replaceCriteriaWithIds = false,
|
||||
): bool {
|
||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||
@@ -58,7 +59,7 @@ class UpdateProjectTicket
|
||||
);
|
||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||
|
||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
|
||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
|
||||
if (! $ticketAttributes['is_blocked']) {
|
||||
$ticketAttributes['blocked_comment'] = null;
|
||||
}
|
||||
@@ -91,7 +92,9 @@ class UpdateProjectTicket
|
||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||
$existingFieldValues = $ticket->fieldValues
|
||||
->keyBy('ticket_field_definition_id');
|
||||
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
||||
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
||||
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||
|
||||
$changes = [];
|
||||
$summaryLabels = [];
|
||||
@@ -209,7 +212,7 @@ class UpdateProjectTicket
|
||||
}
|
||||
|
||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
||||
}
|
||||
|
||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Mcp\Tools\GetProjectConfiguration;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||
use App\Mcp\Tools\ListProjects;
|
||||
use App\Mcp\Tools\ListTicketsByStatus;
|
||||
use App\Mcp\Tools\ListTicketTypes;
|
||||
use App\Mcp\Tools\MoveTicketStatus;
|
||||
use App\Mcp\Tools\SearchTickets;
|
||||
use App\Mcp\Tools\UpdateTicket;
|
||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
use Laravel\Mcp\Server;
|
||||
@@ -16,8 +20,8 @@ use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Attributes\Version;
|
||||
|
||||
#[Name('KanbanGit MCP')]
|
||||
#[Version('0.1.0')]
|
||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||
#[Version('0.2.0')]
|
||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration for compact current project metadata. Get tickets by exact ticket_key or ticket_id, or search_tickets with pagination. Use update_ticket for partial edits; omitted fields are preserved, nullable scalar null clears, and supplied lists replace the entire list. Use move_ticket_status for allowed workflow transitions. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||
class KanbanGitServer extends Server
|
||||
{
|
||||
protected array $tools = [
|
||||
@@ -29,6 +33,10 @@ class KanbanGitServer extends Server
|
||||
MoveTicketStatus::class,
|
||||
CreateTicket::class,
|
||||
ListTicketTypes::class,
|
||||
ListProjects::class,
|
||||
GetProjectConfiguration::class,
|
||||
SearchTickets::class,
|
||||
UpdateTicket::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
@@ -78,6 +79,17 @@ class KanbanGitMcp
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
public function ticketForKey(User $user, string $ticketKey): Ticket
|
||||
{
|
||||
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
|
||||
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
|
||||
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
|
||||
}
|
||||
$this->ensureCanRead($user, $ticket->project);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
@@ -201,6 +213,18 @@ class KanbanGitMcp
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
|
||||
public function paginationPayload(LengthAwarePaginator $paginator): array
|
||||
{
|
||||
return [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'has_more_pages' => $paginator->hasMorePages(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Http\Requests\UpdateProjectTicketRequest;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\User;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
|
||||
class TicketUpdateInput
|
||||
{
|
||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validate(Request $request, Ticket $ticket, User $user): array
|
||||
{
|
||||
$form = new UpdateProjectTicketRequest;
|
||||
$route = new Route('PUT', '/', fn () => null);
|
||||
$route->bind(\Illuminate\Http\Request::create('/', 'PUT'));
|
||||
$route->setParameter('project', $ticket->project);
|
||||
$route->setParameter('ticket', $ticket);
|
||||
$form->setRouteResolver(fn () => $route);
|
||||
$form->setUserResolver(fn () => $user);
|
||||
$rules = $form->rules();
|
||||
foreach ($rules as $key => &$rule) {
|
||||
if (! str_contains($key, '.')) {
|
||||
$rule = ['sometimes', ...$rule];
|
||||
}
|
||||
}
|
||||
unset($rule);
|
||||
$rules['acceptance_criteria'] = ['sometimes', 'array', 'list'];
|
||||
$rules['acceptance_criteria.*'] = ['array:id,text,checked,indent'];
|
||||
$rules['acceptance_criteria.*.id'] = ['sometimes', 'integer', 'min:1', 'distinct', Rule::exists('acceptance_criteria', 'id')->where('ticket_id', $ticket->id)];
|
||||
$rules['acceptance_criteria.*.checked'] = ['sometimes', 'boolean'];
|
||||
$rules['acceptance_criteria.*.indent'] = ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL];
|
||||
$rules['field_values'] = ['sometimes', 'array', 'list'];
|
||||
$rules['field_values.*'] = ['array:ticket_field_definition_id,value_number,value_user_id'];
|
||||
$rules['field_values.*.ticket_field_definition_id'][] = 'distinct';
|
||||
$rules['ticket_status_id'] = ['prohibited'];
|
||||
$rules['acceptance_criteria.*.text'][] = function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (is_string($value) && trim($value) === '') {
|
||||
$fail('Criterion text must not be blank.');
|
||||
}
|
||||
};
|
||||
$input = $request->validate($rules);
|
||||
$previousIndent = -1;
|
||||
foreach (Arr::get($input, 'acceptance_criteria', []) as $index => $item) {
|
||||
$indent = Arr::get($item, 'indent', 0);
|
||||
if ($indent > $previousIndent + 1) {
|
||||
throw ValidationException::withMessages(["acceptance_criteria.{$index}.indent" => 'Indentation cannot skip a level; the first item must have indent 0.']);
|
||||
}
|
||||
$previousIndent = $indent;
|
||||
}
|
||||
$merged = array_replace($this->kanbanGitMcp->updatePayload($ticket), $input);
|
||||
$merged['acceptance_criteria'] = Arr::exists($input, 'acceptance_criteria')
|
||||
? AcceptanceCriteriaChecklistMapper::normalize($input['acceptance_criteria'])
|
||||
: AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria()->get());
|
||||
|
||||
$validator = Validator::make($merged, $form->rules());
|
||||
$form->setValidator($validator);
|
||||
$validator->validate();
|
||||
foreach ($form->after() as $callback) {
|
||||
$callback($validator);
|
||||
}
|
||||
|
||||
$people = User::query()->visibleToProject($ticket->project)->pluck('id')->all();
|
||||
foreach (['assignee_user_id', 'author_user_id'] as $field) {
|
||||
if (Arr::exists($input, $field) && $input[$field] !== null && ! in_array((int) $input[$field], $people, true)) {
|
||||
$validator->errors()->add($field, 'The person must be able to view this project.');
|
||||
}
|
||||
}
|
||||
$definitions = $ticket->project->ticketFieldDefinitions()->get()->keyBy('id');
|
||||
foreach (Arr::get($input, 'field_values', []) as $index => $value) {
|
||||
$definition = $definitions->get($value['ticket_field_definition_id']);
|
||||
$incompatible = $definition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
||||
if (Arr::get($value, $incompatible) !== null) {
|
||||
$validator->errors()->add("field_values.{$index}.{$incompatible}", 'The value must match the custom field type.');
|
||||
}
|
||||
if (Arr::get($value, 'value_user_id') !== null && ! in_array((int) $value['value_user_id'], $people, true)) {
|
||||
$validator->errors()->add("field_values.{$index}.value_user_id", 'The person must be able to view this project.');
|
||||
}
|
||||
}
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
throw new ValidationException($validator);
|
||||
}
|
||||
|
||||
if (Arr::exists($input, 'field_values')) {
|
||||
$replacement = $definitions->map(fn (TicketFieldDefinition $definition): array => [
|
||||
'ticket_field_definition_id' => $definition->id,
|
||||
'value_number' => null,
|
||||
'value_user_id' => null,
|
||||
])->all();
|
||||
foreach ($input['field_values'] as $value) {
|
||||
$replacement[$value['ticket_field_definition_id']] = $value;
|
||||
}
|
||||
$merged['field_values'] = array_values($replacement);
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
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('get_project_configuration')]
|
||||
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
|
||||
#[IsReadOnly]
|
||||
class GetProjectConfiguration extends Tool
|
||||
{
|
||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$input = $request->validate([
|
||||
'project_id' => ['required', 'integer'],
|
||||
'people_page' => ['sometimes', 'integer', 'min:1'],
|
||||
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
|
||||
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
|
||||
|
||||
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
|
||||
$columnPositions = $columns->pluck('position', 'id');
|
||||
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
|
||||
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
|
||||
)->values();
|
||||
|
||||
return Response::structured([
|
||||
'data' => [
|
||||
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
|
||||
'permissions' => [
|
||||
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
],
|
||||
'board_columns' => $columns->toArray(),
|
||||
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
|
||||
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
|
||||
'position' => $status->position, 'board_column_id' => $status->board_column_id,
|
||||
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
|
||||
])->all(),
|
||||
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
|
||||
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
|
||||
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
|
||||
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
|
||||
],
|
||||
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, Type> */
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
|
||||
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
|
||||
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[Name('get_ticket')]
|
||||
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||
#[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||
#[IsReadOnly]
|
||||
class GetTicket extends Tool
|
||||
{
|
||||
@@ -25,11 +25,14 @@ class GetTicket extends Tool
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ticket_id' => ['required', 'integer'],
|
||||
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
|
||||
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
||||
]);
|
||||
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
|
||||
$ticket = isset($validated['ticket_id'])
|
||||
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
|
||||
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
||||
@@ -43,8 +46,8 @@ class GetTicket extends Tool
|
||||
{
|
||||
return [
|
||||
'ticket_id' => $schema->integer()
|
||||
->description('Ticket ID.')
|
||||
->required(),
|
||||
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
||||
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
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('list_projects')]
|
||||
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
|
||||
#[IsReadOnly]
|
||||
class ListProjects extends Tool
|
||||
{
|
||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
|
||||
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
|
||||
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, Type> */
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Mcp\Support\TicketUpdateInput;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
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('update_ticket')]
|
||||
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicket extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly TicketUpdateInput $ticketUpdateInput,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
|
||||
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
|
||||
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||
'meta' => ['updated' => $updated],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, Type> */
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
|
||||
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
|
||||
'description' => $schema->string()->nullable()->description('Description; null clears it.'),
|
||||
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
||||
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
|
||||
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
|
||||
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
|
||||
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
|
||||
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
|
||||
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
|
||||
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
|
||||
'acceptance_criteria' => $schema->array()->items($schema->object([
|
||||
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
|
||||
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
|
||||
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
|
||||
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
|
||||
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid.'),
|
||||
'field_values' => $schema->array()->items($schema->object([
|
||||
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
|
||||
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
|
||||
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
|
||||
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Mcp\Servers\KanbanGitServer;
|
||||
use App\Mcp\Tools\GetProjectConfiguration;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListProjects;
|
||||
use App\Mcp\Tools\SearchTickets;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketDiscoveryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
|
||||
private Project $project;
|
||||
|
||||
private Project $hidden;
|
||||
|
||||
private Ticket $ticket;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->user = User::factory()->create();
|
||||
$this->project = Project::factory()->create(['key' => 'CORE', 'name' => 'Visible']);
|
||||
$this->hidden = Project::factory()->create(['key' => 'HIDE']);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($this->project);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($this->hidden);
|
||||
$this->syncProjectPermissions($this->project, $this->user, ['view']);
|
||||
$this->ticket = $this->newTicket($this->project, 1, ['title' => 'Special needle', 'description' => 'First description', 'assignee_user_id' => $this->user->id]);
|
||||
$this->newTicket($this->project, 2, ['title' => 'Other title', 'description' => 'Needle in description', 'parent_ticket_id' => $this->ticket->id]);
|
||||
$this->newTicket($this->hidden, 1, ['title' => 'Hidden needle']);
|
||||
}
|
||||
|
||||
public function test_exact_key_and_existing_id_lookup_work_and_hidden_tickets_are_denied(): void
|
||||
{
|
||||
foreach ([['ticket_id' => $this->ticket->id], ['ticket_key' => 'CORE-1']] as $input) {
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.id', $this->ticket->id)->etc());
|
||||
}
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'HIDE-1'])->assertHasErrors(['cannot view']);
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-99'])->assertHasErrors(['not found']);
|
||||
}
|
||||
|
||||
#[DataProvider('invalidSelectors')]
|
||||
public function test_key_lookup_requires_exactly_one_valid_selector(array $input): void
|
||||
{
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertHasErrors();
|
||||
}
|
||||
|
||||
public static function invalidSelectors(): array
|
||||
{
|
||||
return [
|
||||
[[]], [['ticket_key' => 'CORE']], [['ticket_key' => 'core-1']], [['ticket_key' => 'CORE-0']],
|
||||
[['ticket_key' => 'CORE-1', 'ticket_id' => 1]], [['ticket_key' => null]],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_search_combines_filters_and_paginates_only_visible_tickets_with_compact_payloads(): void
|
||||
{
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'per_page' => 1])
|
||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
|
||||
->where('data.0.id', $this->ticket->id)->count('data', 1)
|
||||
->where('data.0.status.id', $this->ticket->ticket_status_id)
|
||||
->missing('data.0.description')->missing('data.0.project')
|
||||
->where('meta.total', 2)->where('meta.last_page', 2)->where('meta.has_more_pages', true)->etc());
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'page' => 2, 'per_page' => 1])
|
||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
|
||||
->where('data.0.ticket_key', 'CORE-2')->where('meta.current_page', 2)->where('meta.has_more_pages', false)->etc());
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, [
|
||||
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'ticket_status_id' => $this->ticket->ticket_status_id,
|
||||
'ticket_type_id' => $this->ticket->ticket_type_id, 'assignee_user_id' => $this->user->id, 'parent_ticket_id' => null,
|
||||
])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['parent_ticket_id' => $this->ticket->id, 'assignee_user_id' => null])
|
||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.ticket_key', 'CORE-2')->etc());
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_type_id' => $this->hidden->ticketTypes()->firstOrFail()->id])
|
||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->etc());
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['page' => 10])->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 2)->etc());
|
||||
}
|
||||
|
||||
public function test_search_returns_tickets_without_a_status(): void
|
||||
{
|
||||
$this->ticket->update(['ticket_status_id' => null]);
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => 'CORE-1'])->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)
|
||||
->where('data.0.ticket_status_id', null)->where('data.0.status', null)->etc());
|
||||
}
|
||||
|
||||
public function test_literal_search_does_not_expand_sql_wildcards(): void
|
||||
{
|
||||
$this->ticket->update(['title' => 'A 50%_! literal']);
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => '%_!'])
|
||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
|
||||
}
|
||||
|
||||
public function test_project_list_is_compact_paginated_and_independent_of_assignments_with_team_and_global_access(): void
|
||||
{
|
||||
$this->ticket->update(['assignee_user_id' => null]);
|
||||
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class)->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.key', 'CORE')->missing('data.0.template_state')->where('meta.total', 1)->etc());
|
||||
$team = Team::factory()->create();
|
||||
$team->users()->attach($this->user);
|
||||
$this->syncProjectPermissions($this->hidden, $team, ['view']);
|
||||
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class, ['per_page' => 1, 'page' => 2])->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->hidden->id)->where('meta.total', 2)->etc());
|
||||
$global = User::factory()->create();
|
||||
$this->syncGlobalPermissions($global, ['admin']);
|
||||
KanbanGitServer::actingAs($global)->tool(SearchTickets::class)->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.total', 3)->etc());
|
||||
KanbanGitServer::actingAs(User::factory()->create())->tool(ListProjects::class)->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 0)->etc());
|
||||
}
|
||||
|
||||
public function test_configuration_returns_current_workflow_and_allowed_people_without_full_resources(): void
|
||||
{
|
||||
$first = $this->project->ticketStatuses()->firstOrFail();
|
||||
$column = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
|
||||
$second = TicketStatus::factory()->for($this->project)->for($column)->create(['position' => 1]);
|
||||
TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 1]);
|
||||
$first->allowedTargetStatuses()->sync([$second->id]);
|
||||
$first->update(['name' => 'Fresh workflow']);
|
||||
$otherViewer = User::factory()->create();
|
||||
$this->syncProjectPermissions($this->project, $otherViewer, ['view']);
|
||||
User::factory()->create();
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 1])
|
||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
|
||||
->where('data.project.id', $this->project->id)->missing('data.project.template_state')
|
||||
->where('data.permissions.can_write', false)->where('data.permissions.can_change_author', false)
|
||||
->where('data.ticket_statuses.0.id', $first->id)->where('data.ticket_statuses.0.slug', 'fresh-workflow')
|
||||
->where('data.ticket_statuses.0.allowed_target_status_ids', [$second->id])
|
||||
->where('data.ticket_statuses.0.board_column_id', $first->board_column_id)
|
||||
->has('data.ticket_field_definitions.0.field_type')->has('data.ticket_types.0.is_parent')->has('data.ticket_priorities.0.id')->has('data.board_columns.0.position')
|
||||
->count('data.people', 1)->missing('data.people.0.email')->missing('data.people.0.avatar')
|
||||
->where('meta.people.total', 2)->etc());
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_page' => 2, 'people_per_page' => 1])
|
||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.people.0.id', $otherViewer->id)->etc());
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
|
||||
}
|
||||
|
||||
public function test_numeric_project_keys_and_configured_column_order_are_supported(): void
|
||||
{
|
||||
$this->project->update(['key' => '42']);
|
||||
$this->ticket->update(['ticket_key' => '42-1']);
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => '42-1'])->assertOk();
|
||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => '42-1'])->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)->etc());
|
||||
$later = BoardColumn::factory()->for($this->project)->create(['position' => 2]);
|
||||
$earlier = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
|
||||
$laterStatus = TicketStatus::factory()->for($this->project)->for($later)->create(['position' => 1]);
|
||||
$earlierStatus = TicketStatus::factory()->for($this->project)->for($earlier)->create(['position' => 1]);
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id])->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json
|
||||
->where('data.board_columns.0.id', $earlier->id)
|
||||
->where('data.ticket_statuses.1.id', $earlierStatus->id)
|
||||
->where('data.ticket_statuses.2.id', $laterStatus->id)->etc());
|
||||
}
|
||||
|
||||
public function test_pagination_limits_and_missing_projects_are_validated(): void
|
||||
{
|
||||
foreach ([ListProjects::class, SearchTickets::class] as $tool) {
|
||||
KanbanGitServer::actingAs($this->user)->tool($tool, ['per_page' => 101])->assertHasErrors();
|
||||
KanbanGitServer::actingAs($this->user)->tool($tool, ['page' => 0])->assertHasErrors();
|
||||
}
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => 999])->assertHasErrors(['not found']);
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 101])->assertHasErrors();
|
||||
}
|
||||
|
||||
public function test_application_key_discovers_new_schemas_and_completes_refinement_without_browser_login(): void
|
||||
{
|
||||
$this->syncProjectPermissions($this->project, $this->user, ['write']);
|
||||
$this->withApplicationKey($this->user);
|
||||
$response = $this->postJson('/mcp/kanbangit', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []])->assertOk();
|
||||
$tools = collect($response->json('result.tools'))->keyBy('name');
|
||||
foreach (['list_projects', 'search_tickets', 'get_project_configuration'] as $name) {
|
||||
$this->assertTrue($tools[$name]['annotations']['readOnlyHint']);
|
||||
}
|
||||
$this->assertSame(['ticket_id'], $tools['update_ticket']['inputSchema']['required']);
|
||||
$this->assertArrayHasKey('ticket_key', $tools['get_ticket']['inputSchema']['properties']);
|
||||
$this->assertFalse($tools['update_ticket']['annotations']['destructiveHint']);
|
||||
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'title' => 'Refined via MCP', 'description' => 'Refinement', 'acceptance_criteria' => [['text' => 'Real criterion']]]);
|
||||
$ticket = $this->rpc('get_ticket', ['ticket_key' => 'CORE-1'])->json('result.structuredContent.data.ticket');
|
||||
$this->assertSame('Refined via MCP', $ticket['title']);
|
||||
$this->assertSame('Real criterion', $ticket['acceptance_criteria'][0]['text']);
|
||||
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'acceptance_criteria' => [['id' => $ticket['acceptance_criteria'][0]['id'], 'text' => 'Criterion checked', 'checked' => true]]]);
|
||||
$target = TicketStatus::factory()->for($this->project)->create(['position' => 2]);
|
||||
$this->ticket->ticketStatus->allowedTargetStatuses()->sync([$target->id]);
|
||||
$this->rpc('move_ticket_status', ['ticket_id' => $this->ticket->id, 'ticket_status_id' => $target->id]);
|
||||
$this->assertSame($target->id, $this->ticket->fresh()->ticket_status_id);
|
||||
}
|
||||
|
||||
private function rpc(string $name, array $arguments): TestResponse
|
||||
{
|
||||
return $this->postJson('/mcp/kanbangit', [
|
||||
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => $arguments],
|
||||
])->assertOk()->assertJsonPath('result.isError', false);
|
||||
}
|
||||
|
||||
private function newTicket(Project $project, int $sequence, array $attributes = []): Ticket
|
||||
{
|
||||
return Ticket::factory()->create([
|
||||
'project_id' => $project->id, 'ticket_key' => "{$project->key}-{$sequence}", 'sequence_number' => $sequence,
|
||||
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
|
||||
'ticket_type_id' => $project->ticketTypes()->where('is_parent', $sequence === 1)->firstOrFail()->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Actions\Tickets\SyncProjectTicketFieldValues;
|
||||
use App\Mcp\Servers\KanbanGitServer;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\UpdateTicket;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use Laravel\Mcp\Server\Testing\TestResponse;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UpdateTicketTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
|
||||
private Project $project;
|
||||
|
||||
private Ticket $ticket;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->user = User::factory()->create();
|
||||
$this->project = Project::factory()->create(['key' => 'CORE']);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($this->project);
|
||||
$this->syncProjectPermissions($this->project, $this->user, ['admin']);
|
||||
$this->ticket = $this->newTicket(['description' => 'Original', 'story_points' => 5, 'author_user_id' => $this->user->id]);
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($this->ticket, [
|
||||
['text' => 'First', 'checked' => false, 'indent' => 0],
|
||||
['text' => 'Second', 'checked' => true, 'indent' => 0],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_partial_update_and_no_op_preserve_checklist_ids_and_unrelated_fields(): void
|
||||
{
|
||||
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
|
||||
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
|
||||
->where('data.ticket.description', 'Original')->where('data.ticket.story_points', 5)
|
||||
->where('data.ticket.acceptance_criteria', $before)->where('meta.updated', true)->etc());
|
||||
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
|
||||
$this->assertSame(1, $this->ticket->worklogEntries()->count());
|
||||
$this->assertSame('Refined', $this->ticket->fresh()->title);
|
||||
}
|
||||
|
||||
public function test_checklist_replacement_adds_reorders_indents_checks_and_removes_by_identity(): void
|
||||
{
|
||||
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
|
||||
$replacement = [
|
||||
['text' => 'New root', 'checked' => false, 'indent' => 0],
|
||||
['id' => $before[1]['id'], 'text' => 'Second edited', 'checked' => false, 'indent' => 1],
|
||||
['id' => $before[0]['id'], 'text' => 'First', 'checked' => true, 'indent' => 0],
|
||||
];
|
||||
$this->updateTicket(['acceptance_criteria' => $replacement])->assertOk();
|
||||
$after = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
|
||||
$this->assertNotContains($after[0]['id'], array_column($before, 'id'));
|
||||
$this->assertSame($replacement[1], ['id' => $after[1]['id'], 'text' => $after[1]['text'], 'checked' => $after[1]['checked'], 'indent' => $after[1]['indent']]);
|
||||
$this->assertSame($before[0]['id'], $after[2]['id']);
|
||||
$this->updateTicket(['acceptance_criteria' => $after])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
|
||||
$this->updateTicket(['acceptance_criteria' => [$after[2]]])->assertOk();
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-1'])
|
||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data.ticket.acceptance_criteria', 1)->where('data.ticket.acceptance_criteria.0.id', $before[0]['id'])->etc());
|
||||
$this->updateTicket(['acceptance_criteria' => []])->assertOk();
|
||||
$this->assertSame(0, $this->ticket->acceptanceCriteria()->count());
|
||||
}
|
||||
|
||||
public function test_scalar_null_and_custom_field_replacement_have_explicit_clear_semantics(): void
|
||||
{
|
||||
$number = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
|
||||
$person = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]);
|
||||
$this->updateTicket(['field_values' => [
|
||||
['ticket_field_definition_id' => $number->id, 'value_number' => 0],
|
||||
['ticket_field_definition_id' => $person->id, 'value_user_id' => $this->user->id],
|
||||
]])->assertOk();
|
||||
$this->updateTicket(['description' => null, 'story_points' => null, 'assignee_user_id' => null, 'author_user_id' => null])->assertOk();
|
||||
$this->assertSame(2, $this->ticket->fieldValues()->count());
|
||||
$this->assertNull($this->ticket->fresh()->description);
|
||||
$this->assertNull($this->ticket->fresh()->story_points);
|
||||
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $number->id, 'value_number' => 3]]])->assertOk();
|
||||
$this->assertSame(1, $this->ticket->fieldValues()->count());
|
||||
$this->updateTicket(['field_values' => []])->assertOk();
|
||||
$this->assertSame(0, $this->ticket->fieldValues()->count());
|
||||
}
|
||||
|
||||
#[DataProvider('invalidInputs')]
|
||||
public function test_invalid_payload_does_not_partially_change_ticket(array $input): void
|
||||
{
|
||||
$before = $this->ticket->fresh()->getAttributes();
|
||||
$this->updateTicket(['title' => 'Must not change', ...$input])->assertHasErrors();
|
||||
$this->assertSame($before, $this->ticket->fresh()->getAttributes());
|
||||
$this->assertSame(0, $this->ticket->worklogEntries()->count());
|
||||
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
|
||||
}
|
||||
|
||||
public static function invalidInputs(): array
|
||||
{
|
||||
return [
|
||||
'null title' => [['title' => null]],
|
||||
'null type' => [['ticket_type_id' => null]],
|
||||
'null priority' => [['ticket_priority_id' => null]],
|
||||
'null blocked state' => [['is_blocked' => null]],
|
||||
'invalid criterion text' => [['acceptance_criteria' => [['text' => []]]]],
|
||||
'null criteria' => [['acceptance_criteria' => null]],
|
||||
'null fields' => [['field_values' => null]],
|
||||
'invalid criterion' => [['acceptance_criteria' => [['id' => 99999, 'text' => 'Foreign']]]],
|
||||
'blank criterion' => [['acceptance_criteria' => [['text' => ' ']]]],
|
||||
'skipped indent' => [['acceptance_criteria' => [['text' => 'Child', 'indent' => 1]]]],
|
||||
'negative points' => [['story_points' => -1]],
|
||||
'missing blocker' => [['is_blocked' => true]],
|
||||
'status not editable' => [['ticket_status_id' => 999]],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_foreign_and_duplicate_criterion_ids_are_rejected_atomically(): void
|
||||
{
|
||||
$other = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($other, [['text' => 'Other', 'checked' => false, 'indent' => 0]]);
|
||||
$foreign = $other->acceptanceCriteria()->firstOrFail()->id;
|
||||
$this->updateTicket(['title' => 'Rejected', 'acceptance_criteria' => [['id' => $foreign, 'text' => 'Steal']]])->assertHasErrors();
|
||||
$id = $this->ticket->acceptanceCriteria()->firstOrFail()->id;
|
||||
$this->updateTicket(['acceptance_criteria' => [['id' => $id, 'text' => 'One'], ['id' => $id, 'text' => 'Two']]])->assertHasErrors();
|
||||
$this->assertSame('Original', $this->ticket->fresh()->description);
|
||||
$this->assertSame('Other', $other->acceptanceCriteria()->firstOrFail()->text);
|
||||
}
|
||||
|
||||
public function test_project_read_and_write_permissions_and_author_rules_are_enforced(): void
|
||||
{
|
||||
$this->syncProjectPermissions($this->project, $this->user, ['view']);
|
||||
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
|
||||
$this->syncProjectPermissions($this->project, $this->user, ['write']);
|
||||
$this->updateTicket(['title' => 'Allowed'])->assertOk();
|
||||
$this->updateTicket(['author_user_id' => null])->assertHasErrors(['Only project admins']);
|
||||
$this->syncProjectPermissions($this->project, $this->user, []);
|
||||
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
|
||||
$this->assertSame('Allowed', $this->ticket->fresh()->title);
|
||||
}
|
||||
|
||||
public function test_invalid_people_foreign_metadata_custom_types_and_parent_relations_are_rejected(): void
|
||||
{
|
||||
$hiddenUser = User::factory()->create();
|
||||
$this->updateTicket(['assignee_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
|
||||
$this->updateTicket(['author_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
|
||||
$otherProject = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||
$this->updateTicket(['ticket_type_id' => $otherProject->ticketTypes()->firstOrFail()->id])->assertHasErrors();
|
||||
$this->updateTicket(['ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id])->assertHasErrors();
|
||||
$foreignTicket = Ticket::factory()->for($otherProject)->create();
|
||||
$this->updateTicket(['parent_ticket_id' => $foreignTicket->id])->assertHasErrors();
|
||||
$field = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user']);
|
||||
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_user_id' => $hiddenUser->id]]])->assertHasErrors(['person']);
|
||||
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 2]]])->assertHasErrors(['field type']);
|
||||
$this->updateTicket(['parent_ticket_id' => $this->ticket->id])->assertHasErrors(['own parent']);
|
||||
$task = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
|
||||
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['parent-enabled']);
|
||||
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
|
||||
$this->updateTicket(['ticket_type_id' => $parentType->id])->assertOk();
|
||||
$task->update(['parent_ticket_id' => $this->ticket->id, 'ticket_type_id' => $parentType->id]);
|
||||
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['descendants']);
|
||||
$this->updateTicket(['ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id])->assertHasErrors(['child tickets']);
|
||||
}
|
||||
|
||||
public function test_all_editable_scalar_fields_accept_valid_project_values(): void
|
||||
{
|
||||
$person = User::factory()->create();
|
||||
$this->syncProjectPermissions($this->project, $person, ['view']);
|
||||
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
|
||||
$parent = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2, 'ticket_type_id' => $parentType->id]);
|
||||
$priority = TicketPriority::factory()->for($this->project)->create(['position' => 2]);
|
||||
$input = [
|
||||
'title' => 'All scalar fields', 'description' => 'Updated description',
|
||||
'ticket_type_id' => $parentType->id, 'ticket_priority_id' => $priority->id,
|
||||
'assignee_user_id' => $person->id, 'author_user_id' => $person->id,
|
||||
'parent_ticket_id' => $parent->id, 'story_points' => 0,
|
||||
'is_blocked' => true, 'blocked_comment' => 'Dependency',
|
||||
];
|
||||
$this->updateTicket($input)->assertOk();
|
||||
foreach ($input as $field => $value) {
|
||||
$this->assertSame($value, $this->ticket->fresh()->getAttribute($field));
|
||||
}
|
||||
$this->assertSame(1, $this->ticket->worklogEntries()->count());
|
||||
$this->updateTicket(['parent_ticket_id' => null])->assertOk();
|
||||
$this->assertNull($this->ticket->fresh()->parent_ticket_id);
|
||||
}
|
||||
|
||||
public function test_blocker_validation_uses_preserved_state(): void
|
||||
{
|
||||
$this->updateTicket(['is_blocked' => true, 'blocked_comment' => 'Waiting'])->assertOk();
|
||||
$this->updateTicket(['title' => 'Still blocked'])->assertOk();
|
||||
$this->updateTicket(['blocked_comment' => null])->assertHasErrors(['blocker comment']);
|
||||
$this->updateTicket(['is_blocked' => false])->assertOk();
|
||||
$this->assertNull($this->ticket->fresh()->blocked_comment);
|
||||
}
|
||||
|
||||
public function test_action_failure_rolls_back_scalars_checklist_and_worklog(): void
|
||||
{
|
||||
$this->mock(SyncProjectTicketFieldValues::class)->shouldReceive('handle')->once()->andThrow(new RuntimeException('Sync failed'));
|
||||
try {
|
||||
$this->updateTicket(['title' => 'Rollback', 'acceptance_criteria' => []]);
|
||||
$this->fail('Expected action failure.');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertSame('Sync failed', $exception->getMessage());
|
||||
}
|
||||
$this->assertNotSame('Rollback', $this->ticket->fresh()->title);
|
||||
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
|
||||
$this->assertSame(0, $this->ticket->worklogEntries()->count());
|
||||
}
|
||||
|
||||
private function updateTicket(array $input): TestResponse
|
||||
{
|
||||
return KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $this->ticket->id, ...$input]);
|
||||
}
|
||||
|
||||
private function newTicket(array $attributes = []): Ticket
|
||||
{
|
||||
return Ticket::factory()->create([
|
||||
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
|
||||
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
|
||||
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
|
||||
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -805,6 +805,9 @@ class ProjectControllerTest extends TestCase
|
||||
$this->assertDatabaseMissing('board_columns', [
|
||||
'id' => $boardColumn->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('ticket_statuses', [
|
||||
'id' => $ticketStatus->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('tickets', [
|
||||
'id' => $ticket->id,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user