From 9ed8d0c6cc08e9ab80d8f7219e86b27baa02ae8e Mon Sep 17 00:00:00 2001 From: Twyco Date: Fri, 2 Oct 2026 13:34:00 +0200 Subject: [PATCH] feat(KAN-72): complete MCP ticket editing and discovery --- .../AcceptanceCriteriaChecklistMapper.php | 5 +- app/Actions/Tickets/UpdateProjectTicket.php | 9 +- app/Mcp/Servers/KanbanGitServer.php | 12 +- app/Mcp/Support/KanbanGitMcp.php | 24 ++ app/Mcp/Support/TicketUpdateInput.php | 108 ++++++++ app/Mcp/Tools/GetProjectConfiguration.php | 75 ++++++ app/Mcp/Tools/GetTicket.php | 13 +- app/Mcp/Tools/ListProjects.php | 46 ++++ app/Mcp/Tools/SearchTickets.php | 83 +++++++ app/Mcp/Tools/UpdateTicket.php | 71 ++++++ tests/Feature/Mcp/TicketDiscoveryTest.php | 225 +++++++++++++++++ tests/Feature/Mcp/UpdateTicketTest.php | 235 ++++++++++++++++++ 12 files changed, 894 insertions(+), 12 deletions(-) create mode 100644 app/Mcp/Support/TicketUpdateInput.php create mode 100644 app/Mcp/Tools/GetProjectConfiguration.php create mode 100644 app/Mcp/Tools/ListProjects.php create mode 100644 app/Mcp/Tools/SearchTickets.php create mode 100644 app/Mcp/Tools/UpdateTicket.php create mode 100644 tests/Feature/Mcp/TicketDiscoveryTest.php create mode 100644 tests/Feature/Mcp/UpdateTicketTest.php diff --git a/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php b/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php index edd9efc..277cf06 100644 --- a/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php +++ b/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php @@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper /** * @param array $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 */ diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index 5207647..62e3e51 100644 --- a/app/Actions/Tickets/UpdateProjectTicket.php +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -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 $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( diff --git a/app/Mcp/Servers/KanbanGitServer.php b/app/Mcp/Servers/KanbanGitServer.php index a54fcb1..190a108 100644 --- a/app/Mcp/Servers/KanbanGitServer.php +++ b/app/Mcp/Servers/KanbanGitServer.php @@ -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 = [ diff --git a/app/Mcp/Support/KanbanGitMcp.php b/app/Mcp/Support/KanbanGitMcp.php index ae9e9a2..6d9f90a 100644 --- a/app/Mcp/Support/KanbanGitMcp.php +++ b/app/Mcp/Support/KanbanGitMcp.php @@ -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 */ diff --git a/app/Mcp/Support/TicketUpdateInput.php b/app/Mcp/Support/TicketUpdateInput.php new file mode 100644 index 0000000..fe317d6 --- /dev/null +++ b/app/Mcp/Support/TicketUpdateInput.php @@ -0,0 +1,108 @@ + */ + 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; + } +} diff --git a/app/Mcp/Tools/GetProjectConfiguration.php b/app/Mcp/Tools/GetProjectConfiguration.php new file mode 100644 index 0000000..eee57f4 --- /dev/null +++ b/app/Mcp/Tools/GetProjectConfiguration.php @@ -0,0 +1,75 @@ +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 */ + 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.'), + ]; + } +} diff --git a/app/Mcp/Tools/GetTicket.php b/app/Mcp/Tools/GetTicket.php index 6949573..21f4bda 100644 --- a/app/Mcp/Tools/GetTicket.php +++ b/app/Mcp/Tools/GetTicket.php @@ -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.'), ]; } } diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php new file mode 100644 index 0000000..8b7dbe0 --- /dev/null +++ b/app/Mcp/Tools/ListProjects.php @@ -0,0 +1,46 @@ +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 */ + 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.'), + ]; + } +} diff --git a/app/Mcp/Tools/SearchTickets.php b/app/Mcp/Tools/SearchTickets.php new file mode 100644 index 0000000..b540dc7 --- /dev/null +++ b/app/Mcp/Tools/SearchTickets.php @@ -0,0 +1,83 @@ +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 */ + 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.'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateTicket.php b/app/Mcp/Tools/UpdateTicket.php new file mode 100644 index 0000000..96a223a --- /dev/null +++ b/app/Mcp/Tools/UpdateTicket.php @@ -0,0 +1,71 @@ +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 */ + 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.'), + ]; + } +} diff --git a/tests/Feature/Mcp/TicketDiscoveryTest.php b/tests/Feature/Mcp/TicketDiscoveryTest.php new file mode 100644 index 0000000..396d89f --- /dev/null +++ b/tests/Feature/Mcp/TicketDiscoveryTest.php @@ -0,0 +1,225 @@ +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, + ]); + } +} diff --git a/tests/Feature/Mcp/UpdateTicketTest.php b/tests/Feature/Mcp/UpdateTicketTest.php new file mode 100644 index 0000000..6ff72fa --- /dev/null +++ b/tests/Feature/Mcp/UpdateTicketTest.php @@ -0,0 +1,235 @@ +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, + ]); + } +} -- 2.54.0