Compare commits
1
Commits
d9cb8d0903
...
9577d06c9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9577d06c9b |
@@ -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<int, array{
|
||||
* ticket_field_definition_id: int,
|
||||
* value_number?: float|int|string|null,
|
||||
* value_user_id?: int|string|null
|
||||
* }>,
|
||||
* 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) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<string, mixed> $validated
|
||||
* @return Collection<int, TicketStatus>|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',
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[Name('create_ticket')]
|
||||
#[Description('Create a ticket with all editable ticket fields, including its type, initial status, priority, assignee, author, description, story points, acceptance criteria, blocked state, and custom field values. Set parent_ticket_id to create a child of an existing ticket in the same project whose type allows children. Requires project write permission; overriding the author requires project admin permission. Ticket IDs, keys, sequence numbers, timestamps, and worklog entries are generated automatically.')]
|
||||
#[IsDestructive(false)]
|
||||
class CreateTicket extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly CreateProjectTicket $createProjectTicket,
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$projectInput = $request->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<string, mixed> $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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use App\Actions\Notifications\DeliverNotificationAction;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Actions\Tickets\SyncProjectTicketFieldValues;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Mcp\Servers\KanbanGitServer;
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\TicketType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreateTicketTest 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, ['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<string, array{array<string, mixed>}>
|
||||
*/
|
||||
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<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user