From d9cb8d090346f2b99eedef4cf491b8c9ebef9767 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 9 Sep 2026 08:33:07 +0200 Subject: [PATCH] Add MCP ticket creation with full fields and parent support --- app/Actions/Tickets/CreateProjectTicket.php | 25 +- app/Mcp/Servers/KanbanGitServer.php | 4 +- app/Mcp/Support/KanbanGitMcp.php | 20 + app/Mcp/Tools/CreateTicket.php | 169 ++++++++ tests/Feature/Mcp/CreateTicketTest.php | 433 ++++++++++++++++++++ 5 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 app/Mcp/Tools/CreateTicket.php create mode 100644 tests/Feature/Mcp/CreateTicketTest.php diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index 56d89d6..d44123c 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -19,6 +19,7 @@ class CreateProjectTicket public function __construct( private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, + private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues, ) {} /** @@ -29,7 +30,15 @@ class CreateProjectTicket * text: string * }>, * assignee_user_id?: int|null, + * author_user_id?: int|null, + * blocked_comment?: string|null, * description: string|null, + * field_values?: array, + * is_blocked?: bool, * parent_ticket_id?: int|null, * story_points?: int|null, * ticket_priority_id: int, @@ -50,11 +59,14 @@ class CreateProjectTicket $ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id); $ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id')); + $isBlocked = (bool) Arr::get($attributes, 'is_blocked', false); $ticket = Ticket::query()->create([ 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), - 'author_user_id' => $actor?->id, + 'author_user_id' => Arr::get($attributes, 'author_user_id', $actor?->id), + 'blocked_comment' => $isBlocked ? Arr::get($attributes, 'blocked_comment') : null, 'description' => Arr::get($attributes, 'description'), + 'is_blocked' => $isBlocked, 'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'), 'project_id' => $project->id, 'sequence_number' => $nextSequenceNumber, @@ -68,6 +80,17 @@ class CreateProjectTicket AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria); + $fieldValues = Arr::get($attributes, 'field_values', []); + + if ($fieldValues !== []) { + $this->syncProjectTicketFieldValues->handle( + $ticket, + $project->ticketFieldDefinitions()->get()->keyBy('id'), + collect(), + $fieldValues, + ); + } + $occurredAt = CarbonImmutable::now(); if ($actor !== null) { diff --git a/app/Mcp/Servers/KanbanGitServer.php b/app/Mcp/Servers/KanbanGitServer.php index fb5ab0f..5c6d7b4 100644 --- a/app/Mcp/Servers/KanbanGitServer.php +++ b/app/Mcp/Servers/KanbanGitServer.php @@ -2,6 +2,7 @@ namespace App\Mcp\Servers; +use App\Mcp\Tools\CreateTicket; use App\Mcp\Tools\GetTicket; use App\Mcp\Tools\ListMyAssignedTickets; use App\Mcp\Tools\ListTicketsByStatus; @@ -15,7 +16,7 @@ use Laravel\Mcp\Server\Attributes\Version; #[Name('KanbanGit MCP')] #[Version('0.1.0')] -#[Instructions('Use these tools to read and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially ticket_id and ticket_status_id.')] +#[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 create_ticket with parent_ticket_id to create a child ticket.')] class KanbanGitServer extends Server { protected array $tools = [ @@ -25,6 +26,7 @@ class KanbanGitServer extends Server UpdateTicketDescription::class, UpdateTicketAssignee::class, MoveTicketStatus::class, + CreateTicket::class, ]; protected array $resources = [ diff --git a/app/Mcp/Support/KanbanGitMcp.php b/app/Mcp/Support/KanbanGitMcp.php index c57cbd4..ae9e9a2 100644 --- a/app/Mcp/Support/KanbanGitMcp.php +++ b/app/Mcp/Support/KanbanGitMcp.php @@ -120,6 +120,24 @@ class KanbanGitMcp return $project; } + /** + * @throws AuthorizationException + */ + public function projectForWrite(User $user, int $projectId): Project + { + $project = Project::query()->find($projectId); + + if ($project === null) { + throw ValidationException::withMessages([ + 'project_id' => 'Project was not found.', + ]); + } + + $this->ensureCanWrite($user, $project); + + return $project; + } + /** * @param array $validated * @return Collection|Response @@ -189,10 +207,12 @@ class KanbanGitMcp public function ticketPayload(Ticket $ticket): array { $ticket->load([ + 'acceptanceCriteria', 'assignee', 'author', 'fieldValues.ticketFieldDefinition', 'fieldValues.userValue', + 'parentTicket', 'project', 'ticketPriority', 'ticketStatus.boardColumn', diff --git a/app/Mcp/Tools/CreateTicket.php b/app/Mcp/Tools/CreateTicket.php new file mode 100644 index 0000000..4626cce --- /dev/null +++ b/app/Mcp/Tools/CreateTicket.php @@ -0,0 +1,169 @@ +validate([ + 'project_id' => ['required', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id')); + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)], + 'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)], + 'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)], + 'parent_ticket_id' => ['nullable', 'integer', Rule::exists('tickets', 'id')->where('project_id', $project->id)], + 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], + 'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], + 'story_points' => ['nullable', 'integer', 'min:0'], + 'is_blocked' => ['sometimes', 'boolean'], + 'blocked_comment' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array', 'list'], + 'acceptance_criteria.*' => ['array:text,checked,indent'], + 'acceptance_criteria.*.text' => ['required', 'string'], + 'acceptance_criteria.*.checked' => ['sometimes', 'boolean'], + 'acceptance_criteria.*.indent' => ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL], + 'field_values' => ['sometimes', 'array', 'list'], + 'field_values.*' => ['array:ticket_field_definition_id,value_number,value_user_id'], + 'field_values.*.ticket_field_definition_id' => [ + 'required', 'integer', 'distinct', + Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id), + ], + 'field_values.*.value_number' => ['nullable', 'numeric'], + 'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], + ]); + + $this->validateTicketConstraints($project, $user, $validated); + + $ticket = $this->createProjectTicket->handle($project, $validated, $user); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()), + 'meta' => [ + 'message' => "{$ticket->ticket_key} was created.", + ], + ]); + } + + /** + * @param array $validated + */ + private function validateTicketConstraints(Project $project, User $user, array $validated): void + { + $errors = []; + $parentTicketId = Arr::get($validated, 'parent_ticket_id'); + + if ($parentTicketId !== null) { + $parentTicket = Ticket::query() + ->where('project_id', $project->id) + ->with('ticketType') + ->find($parentTicketId); + + if (! $parentTicket?->ticketType?->is_parent) { + $errors['parent_ticket_id'] = 'Only tickets with a parent-enabled type can be selected as a parent.'; + } + } + + if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) { + $errors['blocked_comment'] = 'Blocked tickets require a blocker comment.'; + } + + if ( + Arr::exists($validated, 'author_user_id') + && (int) Arr::get($validated, 'author_user_id') !== $user->id + && ! $this->permissionService->allows($user, Permission::ProjectAdmin, $project) + ) { + $errors['author_user_id'] = 'Only project admins can change the author.'; + } + + $fieldValues = Arr::get($validated, 'field_values', []); + + if ($fieldValues !== []) { + $fieldDefinitions = $project->ticketFieldDefinitions()->get()->keyBy('id'); + + foreach ($fieldValues as $index => $fieldValue) { + $fieldDefinition = $fieldDefinitions->get((int) Arr::get($fieldValue, 'ticket_field_definition_id')); + $incompatibleValue = $fieldDefinition->field_type === 'user' ? 'value_number' : 'value_user_id'; + + if (Arr::get($fieldValue, $incompatibleValue) !== null) { + $errors["field_values.{$index}.{$incompatibleValue}"] = 'The value must match the custom field type.'; + } + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'project_id' => $schema->integer()->description('Project ID.')->required(), + 'title' => $schema->string()->max(255)->description('Ticket title.')->required(), + 'description' => $schema->string()->nullable()->description('Ticket description.'), + 'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(), + 'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(), + 'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(), + 'parent_ticket_id' => $schema->integer()->nullable()->description('Existing parent ticket ID in the same project. Its type must allow children. Omit or pass null for a top-level ticket.'), + 'assignee_user_id' => $schema->integer()->nullable()->description('User ID to assign. Omit or pass null to leave unassigned.'), + 'author_user_id' => $schema->integer()->nullable()->description('Defaults to the application key owner when omitted. Only project admins may choose another author or pass null for no author.'), + 'story_points' => $schema->integer()->min(0)->nullable()->description('Non-negative story point estimate.'), + 'is_blocked' => $schema->boolean()->default(false)->description('Whether the ticket is blocked. Requires a nonblank blocked_comment when true.'), + 'blocked_comment' => $schema->string()->nullable()->description('Blocker reason. Stored only when is_blocked is true.'), + 'acceptance_criteria' => $schema->array()->nullable() + ->items($schema->object([ + 'text' => $schema->string()->description('Checklist item text.')->required(), + 'checked' => $schema->boolean()->default(false), + 'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0), + ])->withoutAdditionalProperties()) + ->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'), + 'field_values' => $schema->array() + ->items($schema->object([ + 'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(), + 'value_number' => $schema->number()->nullable()->description('Value for a numeric field.'), + 'value_user_id' => $schema->integer()->nullable()->description('User ID for a user field.'), + ])->withoutAdditionalProperties()) + ->description('Custom field values. Supply the value matching each definition type; omit or pass null for an empty value.'), + ]; + } +} diff --git a/tests/Feature/Mcp/CreateTicketTest.php b/tests/Feature/Mcp/CreateTicketTest.php new file mode 100644 index 0000000..b6aad94 --- /dev/null +++ b/tests/Feature/Mcp/CreateTicketTest.php @@ -0,0 +1,433 @@ +user = User::factory()->create(); + $this->project = Project::factory()->create(['key' => 'CORE']); + app(InitializeProjectTicketMetadata::class)->handle($this->project); + $this->syncProjectPermissions($this->project, $this->user, ['write']); + } + + public function test_application_key_can_discover_creation_fields_and_required_inputs(): void + { + $response = $this->withApplicationKey($this->user)->postJson('/mcp/kanbangit', [ + 'id' => 1, + 'jsonrpc' => '2.0', + 'method' => 'tools/list', + 'params' => [], + ])->assertOk(); + + $tool = collect($response->json('result.tools'))->firstWhere('name', 'create_ticket'); + + $this->assertNotNull($tool); + $this->assertFalse($tool['annotations']['destructiveHint']); + $this->assertEqualsCanonicalizing([ + 'project_id', 'title', 'ticket_type_id', 'ticket_status_id', 'ticket_priority_id', + ], $tool['inputSchema']['required']); + $this->assertEqualsCanonicalizing([ + 'project_id', 'title', 'description', 'ticket_type_id', 'ticket_status_id', + 'ticket_priority_id', 'parent_ticket_id', 'assignee_user_id', 'author_user_id', + 'story_points', 'is_blocked', 'blocked_comment', 'acceptance_criteria', 'field_values', + ], array_keys($tool['inputSchema']['properties'])); + $this->assertSame('integer', Arr::get($tool, 'inputSchema.properties.ticket_type_id.type')); + $this->assertSame(['integer', 'null'], Arr::get($tool, 'inputSchema.properties.parent_ticket_id.type')); + $this->assertSame('object', Arr::get($tool, 'inputSchema.properties.acceptance_criteria.items.type')); + $this->assertSame('object', Arr::get($tool, 'inputSchema.properties.field_values.items.type')); + } + + public function test_application_key_can_create_a_child_with_all_editable_fields(): void + { + $assignee = User::factory()->create(); + $author = User::factory()->create(); + $type = TicketType::factory()->for($this->project)->create(['name' => 'Bug', 'is_parent' => false, 'position' => 3]); + $status = TicketStatus::factory()->for($this->project)->create(['name' => 'Ready', 'position' => 2]); + $priority = TicketPriority::factory()->for($this->project)->create(['name' => 'High', 'position' => 2]); + $parent = $this->parentTicket($this->project); + $numberField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']); + $userField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]); + $this->syncProjectPermissions($this->project, $this->user, ['admin']); + + $this->withApplicationKey($this->user)->postJson('/mcp/kanbangit', [ + 'id' => 2, + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'params' => [ + 'name' => 'create_ticket', + 'arguments' => $this->arguments([ + 'title' => 'Fix child ticket creation', + 'description' => 'Created through MCP.', + 'ticket_type_id' => $type->id, + 'ticket_status_id' => $status->id, + 'ticket_priority_id' => $priority->id, + 'parent_ticket_id' => $parent->id, + 'assignee_user_id' => $assignee->id, + 'author_user_id' => $author->id, + 'story_points' => 0, + 'is_blocked' => true, + 'blocked_comment' => 'Waiting for the dependency.', + 'acceptance_criteria' => [ + ['text' => 'Parent criterion', 'checked' => true, 'indent' => 0], + ['text' => 'Child criterion', 'checked' => false, 'indent' => 1], + ], + 'field_values' => [ + ['ticket_field_definition_id' => $numberField->id, 'value_number' => 2.5], + ['ticket_field_definition_id' => $userField->id, 'value_user_id' => $assignee->id], + ], + ]), + ], + ]) + ->assertOk() + ->assertJsonPath('result.isError', false) + ->assertJsonPath('result.structuredContent.meta.message', 'CORE-2 was created.') + ->assertJsonPath('result.structuredContent.data.ticket.ticket_type.id', $type->id) + ->assertJsonPath('result.structuredContent.data.ticket.ticket_status.id', $status->id) + ->assertJsonPath('result.structuredContent.data.ticket.ticket_priority.id', $priority->id) + ->assertJsonPath('result.structuredContent.data.ticket.parent_ticket.id', $parent->id) + ->assertJsonPath('result.structuredContent.data.ticket.author.id', $author->id) + ->assertJsonPath('result.structuredContent.data.ticket.assignee.id', $assignee->id) + ->assertJsonPath('result.structuredContent.data.ticket.is_blocked', true) + ->assertJsonPath('result.structuredContent.data.ticket.acceptance_criteria.1.indent', 1) + ->assertJsonCount(2, 'result.structuredContent.data.ticket.field_values'); + + $ticket = $this->project->tickets()->where('ticket_key', 'CORE-2')->firstOrFail(); + $this->assertSame('Fix child ticket creation', $ticket->title); + $this->assertSame('Created through MCP.', $ticket->description); + $this->assertSame(0, $ticket->story_points); + $this->assertSame('Waiting for the dependency.', $ticket->blocked_comment); + $this->assertSame($parent->id, $ticket->parent_ticket_id); + $this->assertSame($type->id, $ticket->ticket_type_id); + $this->assertSame(2.5, $ticket->fieldValues()->where('ticket_field_definition_id', $numberField->id)->firstOrFail()->value_number); + $this->assertSame($assignee->id, $ticket->fieldValues()->where('ticket_field_definition_id', $userField->id)->firstOrFail()->value_user_id); + $criteria = $ticket->acceptanceCriteria()->get()->keyBy('text'); + $this->assertTrue($criteria['Parent criterion']->is_checked); + $this->assertFalse($criteria['Child criterion']->is_checked); + $this->assertSame($criteria['Parent criterion']->id, $criteria['Child criterion']->parent_acceptance_criterion_id); + $this->assertDatabaseCount('ticket_worklog_entries', 1); + $this->assertDatabaseHas('ticket_worklog_entries', [ + 'ticket_id' => $ticket->id, + 'actor_id' => $this->user->id, + 'entry_type' => 'created', + ]); + } + + public function test_minimal_creation_defaults_optional_fields_and_generates_identity(): void + { + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([ + 'ticket_key' => 'FORGED-999', + 'sequence_number' => 999, + 'id' => 999, + ])) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.ticket_key', 'CORE-1') + ->where('data.ticket.sequence_number', 1) + ->where('data.ticket.author_user_id', $this->user->id) + ->where('data.ticket.assignee_user_id', null) + ->where('data.ticket.parent_ticket_id', null) + ->where('data.ticket.description', null) + ->where('data.ticket.story_points', null) + ->where('data.ticket.is_blocked', false) + ->where('data.ticket.blocked_comment', null) + ->where('data.ticket.acceptance_criteria', []) + ->where('data.ticket.field_values', []) + ->etc()); + + $this->assertDatabaseCount('tickets', 1); + $this->assertDatabaseMissing('tickets', ['id' => 999]); + } + + public function test_admin_can_create_without_an_author_and_with_nullable_fields(): void + { + $this->syncProjectPermissions($this->project, $this->user, ['admin']); + + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([ + 'author_user_id' => null, + 'assignee_user_id' => null, + 'parent_ticket_id' => null, + 'description' => null, + 'story_points' => null, + 'acceptance_criteria' => null, + 'field_values' => [], + 'is_blocked' => false, + 'blocked_comment' => 'Discard this when unblocked.', + ])) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.author_user_id', null) + ->where('data.ticket.blocked_comment', null) + ->where('data.ticket.acceptance_criteria', []) + ->etc()); + } + + public function test_parent_enabled_tickets_can_also_be_children_and_checklists_use_defaults(): void + { + $parent = $this->parentTicket($this->project); + + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([ + 'ticket_type_id' => $parent->ticket_type_id, + 'parent_ticket_id' => $parent->id, + 'author_user_id' => $this->user->id, + 'acceptance_criteria' => [['text' => ' A criterion ']], + ])) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.ticket_type.is_parent', true) + ->where('data.ticket.parent_ticket_id', $parent->id) + ->where('data.ticket.acceptance_criteria.0.text', 'A criterion') + ->where('data.ticket.acceptance_criteria.0.checked', false) + ->where('data.ticket.acceptance_criteria.0.indent', 0) + ->etc()); + } + + public function test_creation_requires_authentication_and_project_write_permission(): void + { + KanbanGitServer::tool(CreateTicket::class, $this->arguments()) + ->assertHasErrors(['Authentication required.']); + + foreach ([[], ['view']] as $permissions) { + $this->syncProjectPermissions($this->project, $this->user, $permissions); + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments()) + ->assertHasErrors(['cannot update tickets']); + } + + $this->assertDatabaseCount('tickets', 0); + } + + public function test_non_admin_cannot_override_or_remove_the_author(): void + { + $author = User::factory()->create(); + + foreach ([$author->id, null] as $authorId) { + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([ + 'author_user_id' => $authorId, + ]))->assertHasErrors(['Only project admins can change the author.']); + } + + $this->assertDatabaseCount('tickets', 0); + } + + public function test_creation_rejects_foreign_project_metadata_and_parent(): void + { + $otherProject = Project::factory()->create(); + app(InitializeProjectTicketMetadata::class)->handle($otherProject); + $parent = $this->parentTicket($otherProject); + $field = TicketFieldDefinition::factory()->for($otherProject)->create(['field_type' => 'number']); + $this->syncProjectPermissions($otherProject, $this->user, ['write']); + + foreach ([ + ['ticket_type_id' => $otherProject->ticketTypes()->firstOrFail()->id], + ['ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id], + ['ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id], + ['parent_ticket_id' => $parent->id], + ['field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 1]]], + ] as $attributes) { + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments($attributes)) + ->assertHasErrors(); + } + + $this->assertSame(0, $this->project->tickets()->count()); + $this->assertDatabaseCount('ticket_worklog_entries', 0); + } + + public function test_creation_rejects_a_parent_without_a_parent_enabled_type(): void + { + $parent = $this->parentTicket($this->project); + $parent->update(['ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id]); + + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([ + 'parent_ticket_id' => $parent->id, + ]))->assertHasErrors(['Only tickets with a parent-enabled type']); + + $this->assertDatabaseCount('tickets', 1); + } + + #[DataProvider('invalidArguments')] + public function test_invalid_arguments_do_not_create_a_ticket(array $attributes): void + { + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments($attributes)) + ->assertHasErrors(); + + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('acceptance_criteria', 0); + $this->assertDatabaseCount('ticket_field_values', 0); + $this->assertDatabaseCount('ticket_worklog_entries', 0); + } + + /** + * @return array}> + */ + public static function invalidArguments(): array + { + return [ + 'missing project' => [['project_id' => null]], + 'unknown project' => [['project_id' => 999999]], + 'invalid project' => [['project_id' => 'invalid']], + 'missing title' => [['title' => null]], + 'blank title' => [['title' => ' ']], + 'long title' => [['title' => str_repeat('a', 256)]], + 'invalid description' => [['description' => []]], + 'missing type' => [['ticket_type_id' => null]], + 'unknown type' => [['ticket_type_id' => 999999]], + 'missing status' => [['ticket_status_id' => null]], + 'unknown status' => [['ticket_status_id' => 999999]], + 'missing priority' => [['ticket_priority_id' => null]], + 'unknown priority' => [['ticket_priority_id' => 999999]], + 'unknown parent' => [['parent_ticket_id' => 999999]], + 'unknown assignee' => [['assignee_user_id' => 999999]], + 'unknown author' => [['author_user_id' => 999999]], + 'negative estimate' => [['story_points' => -1]], + 'fractional estimate' => [['story_points' => 1.5]], + 'invalid blocked flag' => [['is_blocked' => 'yes']], + 'missing blocker reason' => [['is_blocked' => true]], + 'blank blocker reason' => [['is_blocked' => true, 'blocked_comment' => ' ']], + 'invalid checklist' => [['acceptance_criteria' => 'text']], + 'blank checklist text' => [['acceptance_criteria' => [['text' => ' ']]]], + 'invalid checklist flag' => [['acceptance_criteria' => [['text' => 'Test', 'checked' => 'yes']]]], + 'invalid checklist indent' => [['acceptance_criteria' => [['text' => 'Test', 'indent' => 9]]]], + 'unknown field' => [['field_values' => [['ticket_field_definition_id' => 999999, 'value_number' => 1]]]], + 'invalid fields' => [['field_values' => 'text']], + ]; + } + + public function test_custom_fields_reject_duplicate_definitions_and_incompatible_or_invalid_values(): void + { + $numberField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']); + $userField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]); + + foreach ([ + [['ticket_field_definition_id' => $numberField->id, 'value_number' => 1], ['ticket_field_definition_id' => $numberField->id, 'value_number' => 2]], + [['ticket_field_definition_id' => $numberField->id, 'value_user_id' => $this->user->id]], + [['ticket_field_definition_id' => $userField->id, 'value_number' => 2]], + [['ticket_field_definition_id' => $numberField->id, 'value_number' => 'invalid']], + [['ticket_field_definition_id' => $userField->id, 'value_user_id' => 999999]], + ] as $fieldValues) { + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([ + 'field_values' => $fieldValues, + ]))->assertHasErrors(); + } + + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('ticket_field_values', 0); + } + + public function test_zero_and_empty_custom_field_values_are_supported(): void + { + $numberField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']); + $userField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]); + + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([ + 'field_values' => [ + ['ticket_field_definition_id' => $numberField->id, 'value_number' => 0], + ['ticket_field_definition_id' => $userField->id, 'value_user_id' => null], + ], + ]))->assertOk(); + + $this->assertDatabaseCount('ticket_field_values', 1); + $this->assertDatabaseHas('ticket_field_values', [ + 'ticket_field_definition_id' => $numberField->id, + 'value_number' => 0, + 'value_user_id' => null, + ]); + } + + public function test_mcp_creation_queues_one_creation_notification_with_the_key_owner_as_actor(): void + { + $notificationAction = NotificationAction::factory()->webhook()->for($this->project)->create([ + 'event_type' => NotificationEventType::TicketCreate, + ]); + Queue::fake(); + + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments())->assertOk(); + + Queue::assertPushed(DeliverNotificationAction::class, 1); + Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $notificationAction->id + && $job->event->payload()->actorId === $this->user->id + && $job->event->payload()->ticketKey === 'CORE-1' + && $job->event->eventType() === NotificationEventType::TicketCreate); + $this->assertDatabaseCount('notification_delivery_logs', 1); + $this->assertDatabaseCount('ticket_worklog_entries', 1); + } + + public function test_creation_rolls_back_if_custom_field_persistence_fails(): void + { + $field = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']); + $this->mock(SyncProjectTicketFieldValues::class) + ->shouldReceive('handle')->once()->andThrow(new RuntimeException('Unable to save fields.')); + $this->expectException(RuntimeException::class); + + try { + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([ + 'acceptance_criteria' => [['text' => 'Criterion']], + 'field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 1]], + ])); + } finally { + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('acceptance_criteria', 0); + $this->assertDatabaseCount('ticket_field_values', 0); + $this->assertDatabaseCount('ticket_worklog_entries', 0); + $this->assertDatabaseCount('notification_delivery_logs', 0); + } + } + + /** + * @param array $overrides + * @return array + */ + private function arguments(array $overrides = []): array + { + return [ + 'project_id' => $this->project->id, + 'title' => 'MCP ticket', + 'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id, + 'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id, + 'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id, + ...$overrides, + ]; + } + + private function parentTicket(Project $project): Ticket + { + return Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => "{$project->key}-1", + 'ticket_type_id' => $project->ticketTypes()->where('is_parent', true)->firstOrFail()->id, + 'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id, + 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, + ]); + } +}