KAN-73: document and validate MCP ticket description format
This commit is contained in:
@@ -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\n[a](javascript:bad)\n```\n\n`- [ ] task`\n\n <script>example</script>",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"name": "escaped text",
|
||||
"value": "\\<tag> and \\\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": "<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": "",
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user