Compare commits

...
1 Commits
Author SHA1 Message Date
Twyco 5b9248b328 KAN-73: document and validate MCP ticket description format 2026-10-02 14:03:45 +02:00
16 changed files with 643 additions and 28 deletions
@@ -0,0 +1,78 @@
<?php
namespace App\Actions\Tickets\Support;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\Extension\Table\Table;
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
use League\CommonMark\Parser\MarkdownParser;
use League\Uri\Uri;
class TicketDescriptionFormat
{
/** @return array<string, mixed> */
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<string> */
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));
}
}
+7 -3
View File
@@ -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,
@@ -0,0 +1,13 @@
<?php
namespace App\Mcp\Support;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
trait DescribesTicketWriting
{
public function description(): string
{
return parent::description().' '.TicketDescriptionFormat::instructions();
}
}
+2
View File
@@ -7,6 +7,7 @@ use App\Http\Requests\UpdateProjectTicketRequest;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\User;
use App\Rules\VisualTicketDescription;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
@@ -35,6 +36,7 @@ class TicketUpdateInput
}
}
unset($rule);
$rules['description'][] = new VisualTicketDescription;
$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)];
+8 -3
View File
@@ -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(),
+3
View File
@@ -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,
+6 -2
View File
@@ -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.'),
+3
View File
@@ -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,
+9 -3
View File
@@ -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(),
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Rules;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class VisualTicketDescription implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
if ($unsupported !== []) {
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
}
}
}
@@ -0,0 +1,17 @@
{
"format": "Markdown stored verbatim and supported by the Tiptap WYSIWYG editor and ticket detail view.",
"supported": "Supported: plain text, paragraphs, line breaks, headings, bold, italic, strikethrough, ordinary ordered/unordered lists (including nested lists), blockquotes, inline/fenced/indented code, horizontal rules, and safe links.",
"unsupported": "Not visually supported: task checkboxes, tables, HTML, images, or unsafe links. Newly supplied incompatible descriptions reject the entire mutation with a description field error; existing descriptions are preserved when description is omitted.",
"criteria": "Put acceptance criteria exclusively in the structured acceptance_criteria field. Do not repeat them in description as task checkboxes or a copied criteria list. No criteria are automatically extracted or moved.",
"links": "Safe link destinations: http://, https://, mailto:, a root-relative path starting with a single /, or a #fragment. Protocol-relative URLs, other schemes, whitespace, control characters, and backslashes (including percent-encoded forms) are not allowed.",
"example": "## Goal\n\nExplain the **context** and approach.\n\n- Implement the change\n- Verify [the guide](https://example.com/guide)\n\n> 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]"
}
+33 -17
View File
@@ -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;
}
@@ -0,0 +1,178 @@
<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\UpdateTicket;
use App\Mcp\Tools\UpdateTicketDescription;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class TicketDescriptionContractTest extends TestCase
{
use RefreshDatabase;
private Project $project;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($this->project);
$this->syncProjectPermissions($this->project, $this->user, ['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<table></table>";
$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;
}
}
@@ -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) {
@@ -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<tag>\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": "<https://example.test> and https://example.test and test@example.test",
"supported": true
},
{
"name": "literal code",
"value": "```markdown\n- [x] Done\n<table></table>\n![image](bad)\n[a](javascript:bad)\n```\n\n`- [ ] task`\n\n <script>example</script>",
"supported": true
},
{
"name": "escaped text",
"value": "\\<tag> and \\![image](https://example.test)\n\n- \\[x] ordinary list",
"supported": true
},
{
"name": "entities",
"value": "&lt;img src=x onerror=alert(1)&gt;",
"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": "<script>alert(1)</script>",
"supported": false
},
{
"name": "inline html",
"value": "Text <b>bold</b>",
"supported": false
},
{
"name": "html comment",
"value": "<!-- comment -->",
"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
}
]
+70
View File
@@ -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, /<h2>Goal<\/h2>/);
assert.match(html, /<strong>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, /<h2>Goal<\/h2>/);
assert.match(exampleHtml, /<strong>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<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
assert.equal(supportsVisualDescription(markdown), true);