diff --git a/app/Actions/Tickets/Support/TicketDescriptionFormat.php b/app/Actions/Tickets/Support/TicketDescriptionFormat.php new file mode 100644 index 0000000..8a4bc1e --- /dev/null +++ b/app/Actions/Tickets/Support/TicketDescriptionFormat.php @@ -0,0 +1,78 @@ + */ + public static function contract(): array + { + return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR)); + } + + public static function instructions(): string + { + $contract = self::contract(); + + return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria'])) + ." Valid description example (Markdown string):\n".$contract['example']; + } + + public static function criteriaInstructions(): string + { + return self::contract()['criteria']; + } + + public static function safeLink(string $value): bool + { + $contract = self::contract(); + $decoded = rawurldecode($value); + if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded) + || ! preg_match('~'.$contract['link_prefix'].'~i', $value)) { + return false; + } + if (str_starts_with($value, '/') || str_starts_with($value, '#')) { + return true; + } + $url = Uri::tryNew($decoded); + + return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535)); + } + + /** @return list */ + public static function unsupportedContent(string $value): array + { + $environment = new Environment; + $environment->addExtension(new CommonMarkCoreExtension); + $environment->addExtension(new GithubFlavoredMarkdownExtension); + $document = (new MarkdownParser($environment))->parse($value); + $unsupported = []; + foreach ($document->iterator() as $node) { + $type = match (true) { + $node instanceof HtmlBlock, $node instanceof HtmlInline => 'html', + $node instanceof Table => 'table', + $node instanceof Image => 'image', + $node instanceof TaskListItemMarker => 'task', + $node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link', + default => null, + }; + if ($type !== null) { + $unsupported[] = self::contract()['unsupported_tokens'][$type]; + } + } + + return array_values(array_unique($unsupported)); + } +} diff --git a/app/Mcp/Servers/KanbanGitServer.php b/app/Mcp/Servers/KanbanGitServer.php index 190a108..98cd095 100644 --- a/app/Mcp/Servers/KanbanGitServer.php +++ b/app/Mcp/Servers/KanbanGitServer.php @@ -2,6 +2,7 @@ namespace App\Mcp\Servers; +use App\Actions\Tickets\Support\TicketDescriptionFormat; use App\Mcp\Tools\CreateTicket; use App\Mcp\Tools\GetProjectConfiguration; use App\Mcp\Tools\GetTicket; @@ -15,15 +16,18 @@ use App\Mcp\Tools\UpdateTicket; use App\Mcp\Tools\UpdateTicketAssignee; use App\Mcp\Tools\UpdateTicketDescription; use Laravel\Mcp\Server; -use Laravel\Mcp\Server\Attributes\Instructions; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Version; #[Name('KanbanGit MCP')] -#[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.')] +#[Version('0.2.1')] class KanbanGitServer extends Server { + protected function boot(): void + { + $this->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 to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions(); + } + protected array $tools = [ ListMyAssignedTickets::class, ListTicketsByStatus::class, diff --git a/app/Mcp/Support/DescribesTicketWriting.php b/app/Mcp/Support/DescribesTicketWriting.php new file mode 100644 index 0000000..d95ffcf --- /dev/null +++ b/app/Mcp/Support/DescribesTicketWriting.php @@ -0,0 +1,13 @@ +where('ticket_id', $ticket->id)]; diff --git a/app/Mcp/Tools/CreateTicket.php b/app/Mcp/Tools/CreateTicket.php index 4626cce..3d42347 100644 --- a/app/Mcp/Tools/CreateTicket.php +++ b/app/Mcp/Tools/CreateTicket.php @@ -5,11 +5,14 @@ namespace App\Mcp\Tools; use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\CreateProjectTicket; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; +use App\Actions\Tickets\Support\TicketDescriptionFormat; use App\Enums\Permission; +use App\Mcp\Support\DescribesTicketWriting; use App\Mcp\Support\KanbanGitMcp; use App\Models\Project; use App\Models\Ticket; use App\Models\User; +use App\Rules\VisualTicketDescription; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; use Illuminate\Support\Arr; @@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; #[IsDestructive(false)] class CreateTicket extends Tool { + use DescribesTicketWriting; + public function __construct( private readonly KanbanGitMcp $kanbanGitMcp, private readonly CreateProjectTicket $createProjectTicket, @@ -44,7 +49,7 @@ class CreateTicket extends Tool $project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id')); $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], - 'description' => ['nullable', 'string'], + 'description' => ['nullable', 'string', new VisualTicketDescription], 'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)], 'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)], 'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)], @@ -140,7 +145,7 @@ class CreateTicket extends Tool 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.'), + 'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()), 'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(), 'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(), 'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(), @@ -156,7 +161,7 @@ class CreateTicket extends Tool '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.'), + ->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()), 'field_values' => $schema->array() ->items($schema->object([ 'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(), diff --git a/app/Mcp/Tools/MoveTicketStatus.php b/app/Mcp/Tools/MoveTicketStatus.php index b3b2d7e..e57df7d 100644 --- a/app/Mcp/Tools/MoveTicketStatus.php +++ b/app/Mcp/Tools/MoveTicketStatus.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools; use App\Actions\Tickets\MoveProjectTicket; +use App\Mcp\Support\DescribesTicketWriting; use App\Mcp\Support\KanbanGitMcp; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; #[IsDestructive(false)] class MoveTicketStatus extends Tool { + use DescribesTicketWriting; + public function __construct( private readonly KanbanGitMcp $kanbanGitMcp, private readonly MoveProjectTicket $moveProjectTicket, diff --git a/app/Mcp/Tools/UpdateTicket.php b/app/Mcp/Tools/UpdateTicket.php index 96a223a..3fca4d6 100644 --- a/app/Mcp/Tools/UpdateTicket.php +++ b/app/Mcp/Tools/UpdateTicket.php @@ -2,7 +2,9 @@ namespace App\Mcp\Tools; +use App\Actions\Tickets\Support\TicketDescriptionFormat; use App\Actions\Tickets\UpdateProjectTicket; +use App\Mcp\Support\DescribesTicketWriting; use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\TicketUpdateInput; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -20,6 +22,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; #[IsDestructive(false)] class UpdateTicket extends Tool { + use DescribesTicketWriting; + public function __construct( private readonly KanbanGitMcp $kanbanGitMcp, private readonly TicketUpdateInput $ticketUpdateInput, @@ -46,7 +50,7 @@ class UpdateTicket extends Tool 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.'), + 'description' => $schema->string()->nullable()->description('Description; null clears it. '.TicketDescriptionFormat::instructions()), '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.'), @@ -60,7 +64,7 @@ class UpdateTicket extends Tool '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.'), + ])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid. '.TicketDescriptionFormat::criteriaInstructions()), '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.'), diff --git a/app/Mcp/Tools/UpdateTicketAssignee.php b/app/Mcp/Tools/UpdateTicketAssignee.php index 2215ddc..1b5f365 100644 --- a/app/Mcp/Tools/UpdateTicketAssignee.php +++ b/app/Mcp/Tools/UpdateTicketAssignee.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools; use App\Actions\Tickets\UpdateProjectTicket; +use App\Mcp\Support\DescribesTicketWriting; use App\Mcp\Support\KanbanGitMcp; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; @@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; #[IsDestructive(false)] class UpdateTicketAssignee extends Tool { + use DescribesTicketWriting; + public function __construct( private readonly KanbanGitMcp $kanbanGitMcp, private readonly UpdateProjectTicket $updateProjectTicket, diff --git a/app/Mcp/Tools/UpdateTicketDescription.php b/app/Mcp/Tools/UpdateTicketDescription.php index fc816bf..1dfa492 100644 --- a/app/Mcp/Tools/UpdateTicketDescription.php +++ b/app/Mcp/Tools/UpdateTicketDescription.php @@ -2,10 +2,14 @@ namespace App\Mcp\Tools; +use App\Actions\Tickets\Support\TicketDescriptionFormat; use App\Actions\Tickets\UpdateProjectTicket; +use App\Mcp\Support\DescribesTicketWriting; use App\Mcp\Support\KanbanGitMcp; +use App\Rules\VisualTicketDescription; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; +use Illuminate\Support\Arr; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; #[IsDestructive(false)] class UpdateTicketDescription extends Tool { + use DescribesTicketWriting; + public function __construct( private readonly KanbanGitMcp $kanbanGitMcp, private readonly UpdateProjectTicket $updateProjectTicket, @@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool public function handle(Request $request): Response|ResponseFactory { $validated = $request->validate([ - 'description' => ['nullable', 'string'], + 'description' => ['nullable', 'string', new VisualTicketDescription], 'ticket_id' => ['required', 'integer'], ]); @@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); $payload = [ ...$this->kanbanGitMcp->updatePayload($ticket), - 'description' => $validated['description'] ?? null, + ...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []), ]; $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload); @@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool return [ 'description' => $schema->string() ->nullable() - ->description('New ticket description. Pass null to clear it.'), + ->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()), 'ticket_id' => $schema->integer() ->description('Ticket ID.') ->required(), diff --git a/app/Rules/VisualTicketDescription.php b/app/Rules/VisualTicketDescription.php new file mode 100644 index 0000000..ce68130 --- /dev/null +++ b/app/Rules/VisualTicketDescription.php @@ -0,0 +1,21 @@ + Keep the scope clear.\n\nUse `code` when needed.", + "unsupported_tokens": { + "html": "HTML", + "table": "tables", + "image": "images", + "task": "task checkboxes", + "unsafe_link": "unsafe links" + }, + "link_prefix": "^(?:https?://|mailto:|/(?!/)|#)", + "unsafe_link_characters": "[\\s\\\\\\x00-\\x1f\\x7f]" +} diff --git a/resources/js/lib/ticket-description.ts b/resources/js/lib/ticket-description.ts index ceb5379..659dea4 100644 --- a/resources/js/lib/ticket-description.ts +++ b/resources/js/lib/ticket-description.ts @@ -2,30 +2,40 @@ import type { JSONContent } from '@tiptap/core'; import { Markdown, MarkdownManager } from '@tiptap/markdown'; import StarterKit from '@tiptap/starter-kit'; import { h } from 'vue'; +import descriptionFormat from './ticket-description-format.json'; import type { VNodeChild } from 'vue'; export function safeDescriptionLink(value: unknown): string | null { if ( typeof value !== 'string' || - /[\s\\]/u.test(value) || - [...value].some( - (character) => - character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127, - ) + new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(value) ) { return null; } + try { + if ( + new RegExp(descriptionFormat.unsafe_link_characters, 'u').test( + decodeURIComponent(value), + ) + ) { + return null; + } + } catch { + return null; + } if (/^(https?:\/\/|mailto:)/i.test(value)) { try { - const url = new URL(value); - return ['http:', 'https:', 'mailto:'].includes(url.protocol) + new URL(value); + return new RegExp(descriptionFormat.link_prefix, 'i').test(value) ? value : null; } catch { return null; } } - return /^(\/(?!\/)|#)/.test(value) ? value : null; + return new RegExp(descriptionFormat.link_prefix, 'i').test(value) + ? value + : null; } export function descriptionExtensions() { @@ -55,15 +65,21 @@ const markdown = new MarkdownManager({ /** Unsupported Markdown stays editable verbatim instead of being silently discarded. */ export function supportsVisualDescription(value: string): boolean { let supported = true; - markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => { - if ( - ['html', 'table', 'image'].includes(token.type) || - (token.type === 'list_item' && token.task) || - (token.type === 'link' && !safeDescriptionLink(token.href)) - ) { - supported = false; - } - }); + markdown.instance.walkTokens( + markdown.instance.lexer(value, { gfm: true, breaks: true }), + (token) => { + if ( + Object.hasOwn( + descriptionFormat.unsupported_tokens, + token.type, + ) || + (token.type === 'list_item' && token.task) || + (token.type === 'link' && !safeDescriptionLink(token.href)) + ) { + supported = false; + } + }, + ); return supported; } diff --git a/tests/Feature/Mcp/TicketDescriptionContractTest.php b/tests/Feature/Mcp/TicketDescriptionContractTest.php new file mode 100644 index 0000000..de6b1e7 --- /dev/null +++ b/tests/Feature/Mcp/TicketDescriptionContractTest.php @@ -0,0 +1,178 @@ +user = User::factory()->create(); + $this->project = Project::factory()->create(['key' => 'CORE']); + app(InitializeProjectTicketMetadata::class)->handle($this->project); + $this->syncProjectPermissions($this->project, $this->user, ['view', 'write']); + } + + public function test_discovery_exposes_the_shared_contract_for_every_writing_tool_and_field_for_any_key_owner(): void + { + foreach ([$this->user, User::factory()->create()] as $user) { + $this->syncProjectPermissions($this->project, $user, ['view', 'write']); + $this->withApplicationKey($user); + $this->postJson('/mcp/kanbangit', [ + 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => [], 'clientInfo' => ['name' => 'Contract test', 'version' => '1.0']], + ])->assertOk()->assertJsonPath('result.instructions', fn (string $value): bool => str_contains($value, TicketDescriptionFormat::instructions())); + $response = $this->postJson('/mcp/kanbangit', [ + 'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => [], + ])->assertOk(); + $writers = []; + foreach ($response->json('result.tools') as $tool) { + $fields = $tool['inputSchema']['properties']; + if (! ($tool['annotations']['readOnlyHint'] ?? false)) { + $writers[] = $tool['name']; + $this->assertStringContainsString(TicketDescriptionFormat::instructions(), $tool['description'], $tool['name']); + } + if (isset($fields['description'])) { + $this->assertStringContainsString(TicketDescriptionFormat::instructions(), $fields['description']['description'], $tool['name']); + } + if (isset($fields['acceptance_criteria'])) { + $this->assertStringContainsString(TicketDescriptionFormat::criteriaInstructions(), $fields['acceptance_criteria']['description'], $tool['name']); + } + } + $this->assertContains('create_ticket', $writers); + $this->assertContains('update_ticket', $writers); + $this->assertContains('update_ticket_description', $writers); + } + } + + #[DataProvider('descriptionCases')] + public function test_backend_and_editor_share_compatibility_cases(string $value, bool $supported): void + { + $this->assertSame($supported, TicketDescriptionFormat::unsupportedContent($value) === []); + } + + public static function descriptionCases(): array + { + $cases = json_decode(file_get_contents(__DIR__.'/../../Fixtures/ticket-description-cases.json'), true, flags: JSON_THROW_ON_ERROR); + + return collect($cases)->mapWithKeys(fn (array $case): array => [$case['name'] => [$case['value'], $case['supported']]])->all(); + } + + #[DataProvider('unsupportedDescriptions')] + public function test_incompatible_description_rejects_creation_and_every_update_without_partial_changes(string $description): void + { + $projectBefore = $this->project->fresh()->getAttributes(); + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->createPayload($description)) + ->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']); + $this->assertDatabaseCount('tickets', 0); + $this->assertDatabaseCount('ticket_worklog_entries', 0); + $this->assertSame($projectBefore, $this->project->fresh()->getAttributes()); + + $ticket = $this->ticket(); + $before = $ticket->fresh()->getAttributes(); + foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) { + KanbanGitServer::actingAs($this->user)->tool($tool, [ + 'ticket_id' => $ticket->id, 'description' => $description, + 'title' => 'Must not change', 'story_points' => 99, + 'acceptance_criteria' => [['text' => 'Must not replace']], + ])->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']); + $this->assertSame($before, $ticket->fresh()->getAttributes()); + $this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text); + $this->assertDatabaseCount('ticket_worklog_entries', 0); + } + } + + public static function unsupportedDescriptions(): array + { + return array_map(fn (array $case): array => [$case[0]], array_filter(self::descriptionCases(), fn (array $case): bool => ! $case[1])); + } + + public function test_compatible_descriptions_are_stored_verbatim_and_criteria_remain_separate_and_null_clears(): void + { + $description = TicketDescriptionFormat::contract()['example']; + KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, [ + ...$this->createPayload($description), 'acceptance_criteria' => [['text' => 'Separate criterion']], + ])->assertOk(); + $ticket = $this->project->tickets()->sole(); + $this->assertSame($description, $ticket->description); + $this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text); + $this->assertSame([], TicketDescriptionFormat::unsupportedContent($description)); + $this->withoutVite(); + $this->actingAs($this->user)->get(route('projects.tickets.show', [$this->project, $ticket])) + ->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description)->where('ticket.acceptance_criteria.0.text', 'Separate criterion')); + $process = new Process(['node', 'tests/Fixtures/ticket-description.mjs', '--stored-description'], base_path()); + $process->setInput(json_encode(['description' => $ticket->description, 'criteria' => $ticket->acceptanceCriteria()->get()->toArray()], JSON_THROW_ON_ERROR)); + $process->setTimeout(60); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) { + $updated = "> Quote\n\n**Updated**\n\n```markdown\n- [x] literal example\n```"; + KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => $updated])->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $updated)->etc()); + $this->assertSame($updated, $ticket->fresh()->description); + KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => null])->assertOk(); + $this->assertNull($ticket->fresh()->description); + $this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text); + } + } + + public function test_updates_without_description_preserve_legacy_source_and_checklist_identity(): void + { + $ticket = $this->ticket(); + $legacy = "## Legacy\n\n- [x] Keep verbatim\n\n
"; + $ticket->update(['description' => $legacy]); + $criterion = $ticket->acceptanceCriteria()->sole(); + KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $ticket->id, 'title' => 'Updated title']) + ->assertOk(); + $this->assertSame($legacy, $ticket->fresh()->description); + $this->assertSame($criterion->id, $ticket->acceptanceCriteria()->sole()->id); + KanbanGitServer::actingAs($this->user)->tool(UpdateTicketDescription::class, ['ticket_id' => $ticket->id])->assertOk(); + $this->assertSame($legacy, $ticket->fresh()->description); + KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_id' => $ticket->id])->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $legacy)->etc()); + } + + private function createPayload(?string $description): array + { + return [ + 'project_id' => $this->project->id, 'title' => 'Formatted ticket', 'description' => $description, + '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, + ]; + } + + private function ticket(): Ticket + { + $ticket = Ticket::factory()->create([ + ...collect($this->createPayload('Original description'))->except('project_id')->all(), + 'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1, + ]); + $ticket->acceptanceCriteria()->create(['text' => 'Separate criterion', 'checked' => false, 'indent' => 0, 'position' => 1]); + + return $ticket; + } +} diff --git a/tests/Fixtures/ticket-description-browser.mjs b/tests/Fixtures/ticket-description-browser.mjs index 451b168..b99a877 100644 --- a/tests/Fixtures/ticket-description-browser.mjs +++ b/tests/Fixtures/ticket-description-browser.mjs @@ -3,6 +3,7 @@ import TicketDescriptionEditor from '../../resources/js/components/tickets/Ticke import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue'; import { createLocalization } from '../../resources/js/lib/localization'; import '../../resources/css/app.css'; +import descriptionFormat from '../../resources/js/lib/ticket-description-format.json'; const original = 'First line\nSecond line\n\n**Existing bold**'; const state = reactive({ @@ -199,6 +200,33 @@ try { state.value = '# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.'; await tick(); + state.value = descriptionFormat.example; + await tick(); + assert( + document.querySelector('#description h2')?.textContent === 'Goal', + 'MCP example opens formatted in WYSIWYG', + ); + assert( + document.querySelector('#description strong')?.textContent === + 'context', + 'MCP emphasis is formatted', + ); + assert( + document.querySelectorAll('#description li').length === 2, + 'MCP ordinary lists render', + ); + assert( + !document.querySelector('textarea#description'), + 'MCP example avoids source fallback', + ); + state.value = '## Legacy\n\n- [ ] Criterion'; + await tick(); + assert( + document.querySelector('textarea#description')?.value === state.value, + 'Legacy task checkboxes retain original source', + ); + state.value = descriptionFormat.example; + await tick(); state.result = 'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.'; } catch (error) { diff --git a/tests/Fixtures/ticket-description-cases.json b/tests/Fixtures/ticket-description-cases.json new file mode 100644 index 0000000..3e88fd2 --- /dev/null +++ b/tests/Fixtures/ticket-description-cases.json @@ -0,0 +1,167 @@ +[ + { + "name": "plain text", + "value": "München 🚀 & details\nSecond line", + "supported": true + }, + { + "name": "formatting", + "value": "# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n\n```\n\n[Link](https://example.test/path)\n\n---", + "supported": true + }, + { + "name": "setext heading", + "value": "Title\n===\n\nText", + "supported": true + }, + { + "name": "safe links", + "value": "[http](http://example.test) [mail](mailto:test@example.test) [root](/projects/1) [fragment](#part)", + "supported": true + }, + { + "name": "reference link", + "value": "[Guide][ref]\n\n[ref]: https://example.test", + "supported": true + }, + { + "name": "autolinks", + "value": " and https://example.test and test@example.test", + "supported": true + }, + { + "name": "literal code", + "value": "```markdown\n- [x] Done\n
\n![image](bad)\n[a](javascript:bad)\n```\n\n`- [ ] task`\n\n ", + "supported": true + }, + { + "name": "escaped text", + "value": "\\ and \\![image](https://example.test)\n\n- \\[x] ordinary list", + "supported": true + }, + { + "name": "entities", + "value": "<img src=x onerror=alert(1)>", + "supported": true + }, + { + "name": "empty", + "value": "", + "supported": true + }, + { + "name": "task", + "value": "## Plan\n\n- [ ] Criteria", + "supported": false + }, + { + "name": "checked", + "value": "- [x] Done", + "supported": false + }, + { + "name": "nested task", + "value": "> 1. [X] Done", + "supported": false + }, + { + "name": "table", + "value": "| A | B |\n| - | - |\n| a | b |", + "supported": false + }, + { + "name": "html", + "value": "", + "supported": false + }, + { + "name": "inline html", + "value": "Text bold", + "supported": false + }, + { + "name": "html comment", + "value": "", + "supported": false + }, + { + "name": "image", + "value": "![Image](https://example.test/image.png)", + "supported": false + }, + { + "name": "reference image", + "value": "![alt][img]\n\n[img]: https://example.test/image.png", + "supported": false + }, + { + "name": "javascript", + "value": "[unsafe](javascript:alert%281%29)", + "supported": false + }, + { + "name": "data", + "value": "[unsafe](data:text/html,test)", + "supported": false + }, + { + "name": "protocol relative", + "value": "[unsafe](//evil.test)", + "supported": false + }, + { + "name": "relative", + "value": "[unsafe](other/page)", + "supported": false + }, + { + "name": "unsafe reference", + "value": "[unsafe][ref]\n\n[ref]: javascript:alert(1)", + "supported": false + }, + { + "name": "malformed https", + "value": "[unsafe](https://)", + "supported": false + }, + { + "name": "backslash", + "value": "[unsafe](https://example.test/a\\\\b)", + "supported": false + }, + { + "name": "invalid ipv6", + "value": "[unsafe](https://[invalid]/)", + "supported": false + }, + { + "name": "encoded backslash", + "value": "[unsafe](https://example.test/%5cpath)", + "supported": false + }, + { + "name": "encoded whitespace", + "value": "[unsafe](https://example.test/a%20b)", + "supported": false + }, + { + "name": "invalid encoding", + "value": "[unsafe](https://example.test/%ff)", + "supported": false + }, + { + "name": "too large port", + "value": "[unsafe](https://example.test:99999)", + "supported": false + }, + { + "name": "ipv6", + "value": "[safe](https://[::1]/path)", + "supported": true + }, + { + "name": "international domain", + "value": "[safe](https://münchen.example/🚀)", + "supported": true + } +] diff --git a/tests/Fixtures/ticket-description.mjs b/tests/Fixtures/ticket-description.mjs index 6b402ce..7436860 100644 --- a/tests/Fixtures/ticket-description.mjs +++ b/tests/Fixtures/ticket-description.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import vue from '@vitejs/plugin-vue'; @@ -11,7 +12,24 @@ const serve = process.argv.includes('--serve'); const server = await createServer({ configFile: false, plugins: [vue(), ...(serve ? [tailwind()] : [])], + optimizeDeps: { + include: [ + '@tiptap/core', + '@tiptap/pm/state', + '@tiptap/vue-3', + '@tiptap/markdown', + '@tiptap/starter-kit', + ], + }, resolve: { + dedupe: [ + 'vue', + '@tiptap/core', + '@tiptap/pm', + 'prosemirror-state', + 'prosemirror-model', + 'prosemirror-view', + ], alias: { '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), }, @@ -56,6 +74,58 @@ if (serve) { renderToString( createSSRApp({ render: () => h(Description, { description }) }), ); + const cases = JSON.parse( + readFileSync( + new URL('./ticket-description-cases.json', import.meta.url), + 'utf8', + ), + ); + const contract = JSON.parse( + readFileSync( + new URL( + '../../resources/js/lib/ticket-description-format.json', + import.meta.url, + ), + 'utf8', + ), + ); + cases.push({ + name: 'MCP contract example', + value: contract.example, + supported: true, + }); + for (const item of cases) { + assert.equal( + supportsVisualDescription(item.value), + item.supported, + item.name, + ); + if (item.supported) { + assert.ok(parseDescription(item.value)); + await render(item.value); + } + } + if (process.argv.includes('--stored-description')) { + const saved = JSON.parse(readFileSync(0, 'utf8')); + assert.equal(supportsVisualDescription(saved.description), true); + const html = await render(saved.description); + assert.match(html, /

Goal<\/h2>/); + assert.match(html, /context<\/strong>/); + assert.equal( + parseDescription(saved.description).content[0].type, + 'heading', + ); + for (const criterion of saved.criteria) { + assert.ok( + !html.includes(criterion.text), + 'Criteria stay in the separate checklist', + ); + } + } + const exampleHtml = await render(contract.example); + assert.match(exampleHtml, /

Goal<\/h2>/); + assert.match(exampleHtml, /context<\/strong>/); + assert.doesNotMatch(exampleHtml, /acceptance_criteria|task checkboxes/); const markdown = '# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n\n```\n\n[Link](https://example.test/path)\n\n---'; assert.equal(supportsVisualDescription(markdown), true);