From af6fc66e09faddf61f8662030696b9f6e54e98ee Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 20:48:59 +0000 Subject: [PATCH] Add checklist acceptance criteria --- app/Actions/Tickets/CreateProjectTicket.php | 16 +- .../Support/TicketAcceptanceCriteriaData.php | 316 ++++++++++++++++++ app/Actions/Tickets/UpdateProjectTicket.php | 79 ++++- .../Requests/StoreProjectTicketRequest.php | 21 +- .../Requests/UpdateProjectTicketRequest.php | 21 +- app/Http/Resources/TicketResource.php | 7 +- app/Mcp/Support/KanbanGitMcp.php | 11 +- app/Models/Ticket.php | 2 + ...eptance_criteria_data_to_tickets_table.php | 28 ++ resources/js/components/TicketDetailsCard.vue | 135 +++++++- resources/js/lib/acceptance-criteria.ts | 146 ++++++++ resources/js/pages/projects/TicketCreate.vue | 151 +++++++-- .../types/models/acceptance-criteria-model.ts | 10 + resources/js/types/models/ticket-model.ts | 4 +- tests/Feature/Api/TicketApiTest.php | 30 +- .../Projects/ProjectTicketControllerTest.php | 35 +- 16 files changed, 961 insertions(+), 51 deletions(-) create mode 100644 app/Actions/Tickets/Support/TicketAcceptanceCriteriaData.php create mode 100644 database/migrations/2026_04_24_203500_add_acceptance_criteria_data_to_tickets_table.php create mode 100644 resources/js/lib/acceptance-criteria.ts create mode 100644 resources/js/types/models/acceptance-criteria-model.ts diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index 7ea665a..2f8c551 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -4,6 +4,7 @@ namespace App\Actions\Tickets; use App\Actions\Notifications\BuildTicketCreatedPayload; use App\Actions\Notifications\TicketCreated; +use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Enums\TicketWorklogEntryType; use App\Models\Project; use App\Models\Ticket; @@ -22,7 +23,14 @@ class CreateProjectTicket /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array{ + * summary: string|null, + * items: array + * }, * assignee_user_id?: int|null, * description: string|null, * story_points?: int|null, @@ -35,6 +43,9 @@ class CreateProjectTicket public function handle(Project $project, array $attributes, ?User $actor = null): Ticket { return DB::transaction(function () use ($actor, $attributes, $project): Ticket { + $acceptanceCriteria = TicketAcceptanceCriteriaData::normalize( + Arr::get($attributes, 'acceptance_criteria'), + ); $nextSequenceNumber = (int) Ticket::query() ->where('project_id', $project->id) ->max('sequence_number') + 1; @@ -43,7 +54,8 @@ class CreateProjectTicket ->findOrFail(Arr::get($attributes, 'ticket_status_id')); $ticket = Ticket::query()->create([ - 'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'), + 'acceptance_criteria' => TicketAcceptanceCriteriaData::toLegacyText($acceptanceCriteria), + 'acceptance_criteria_data' => TicketAcceptanceCriteriaData::forStorage($acceptanceCriteria), 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), 'author_user_id' => $actor?->id, 'description' => Arr::get($attributes, 'description'), diff --git a/app/Actions/Tickets/Support/TicketAcceptanceCriteriaData.php b/app/Actions/Tickets/Support/TicketAcceptanceCriteriaData.php new file mode 100644 index 0000000..9a066f3 --- /dev/null +++ b/app/Actions/Tickets/Support/TicketAcceptanceCriteriaData.php @@ -0,0 +1,316 @@ + + * } + */ + public static function fromStored(mixed $storedData, ?string $legacyText): array + { + $normalizedStoredData = is_array($storedData) + ? self::normalize($storedData) + : self::empty(); + + if (self::hasContent($normalizedStoredData)) { + return $normalizedStoredData; + } + + return self::normalize($legacyText); + } + + /** + * @return array{ + * summary: string|null, + * items: array + * } + */ + public static function normalize(mixed $value): array + { + if (is_array($value)) { + return self::normalizeFromArray($value); + } + + if (is_string($value) || $value === null) { + return self::normalizeFromString($value); + } + + return self::empty(); + } + + /** + * @param array{ + * summary: string|null, + * items: array + * } $acceptanceCriteria + */ + public static function hasContent(array $acceptanceCriteria): bool + { + return $acceptanceCriteria['summary'] !== null + || $acceptanceCriteria['items'] !== []; + } + + /** + * @param array{ + * summary: string|null, + * items: array + * } $acceptanceCriteria + * @return array{ + * summary: string|null, + * items: array + * }|null + */ + public static function forStorage(array $acceptanceCriteria): ?array + { + $normalized = self::normalize($acceptanceCriteria); + + return self::hasContent($normalized) + ? $normalized + : null; + } + + /** + * @param array{ + * summary: string|null, + * items: array + * } $acceptanceCriteria + */ + public static function toLegacyText(array $acceptanceCriteria): ?string + { + $normalized = self::normalize($acceptanceCriteria); + $lines = []; + + if ($normalized['summary'] !== null) { + $lines[] = $normalized['summary']; + } + + if ($normalized['items'] !== []) { + if ($normalized['summary'] !== null) { + $lines[] = ''; + } + + foreach ($normalized['items'] as $item) { + $lines[] = sprintf( + '%s- [%s] %s', + str_repeat(' ', $item['indent']), + $item['checked'] ? 'x' : ' ', + $item['text'], + ); + } + } + + $legacyText = trim(implode("\n", $lines)); + + return $legacyText === '' ? null : $legacyText; + } + + /** + * @return array{ + * summary: string|null, + * items: array + * } + */ + private static function empty(): array + { + return [ + 'summary' => null, + 'items' => [], + ]; + } + + /** + * @param array $value + * @return array{ + * summary: string|null, + * items: array + * } + */ + private static function normalizeFromArray(array $value): array + { + $summary = trim((string) Arr::get($value, 'summary', '')); + $rawItems = Arr::get($value, 'items', []); + + if (! is_array($rawItems)) { + $rawItems = []; + } + + $items = []; + + foreach ($rawItems as $rawItem) { + if (! is_array($rawItem)) { + continue; + } + + $text = trim((string) Arr::get($rawItem, 'text', '')); + + if ($text === '') { + continue; + } + + $rawChecked = Arr::get($rawItem, 'checked', false); + $rawIndent = Arr::get($rawItem, 'indent', 0); + + $items[] = [ + 'checked' => filter_var($rawChecked, FILTER_VALIDATE_BOOLEAN), + 'indent' => self::clampIndent((int) $rawIndent), + 'text' => $text, + ]; + } + + return [ + 'summary' => $summary === '' ? null : $summary, + 'items' => $items, + ]; + } + + /** + * @return array{ + * summary: string|null, + * items: array + * } + */ + private static function normalizeFromString(?string $value): array + { + $text = trim((string) $value); + + if ($text === '') { + return self::empty(); + } + + $lines = preg_split('/\R/u', $text); + + if (! is_array($lines)) { + return [ + 'summary' => $text, + 'items' => [], + ]; + } + + $summary = null; + $items = []; + + foreach ($lines as $line) { + $trimmedLine = trim($line); + + if ($trimmedLine === '') { + continue; + } + + if ($summary === null) { + $summary = $trimmedLine; + + continue; + } + + $parsedItem = self::parseChecklistItem($line); + + if ($parsedItem === null) { + continue; + } + + $items[] = $parsedItem; + } + + return [ + 'summary' => $summary, + 'items' => $items, + ]; + } + + /** + * @return array{checked: bool, indent: int, text: string}|null + */ + private static function parseChecklistItem(string $line): ?array + { + $line = rtrim(str_replace("\t", ' ', $line)); + + if ($line === '') { + return null; + } + + if (! preg_match('/^(?\s*)(?.+)$/u', $line, $matches)) { + return null; + } + + $content = trim((string) Arr::get($matches, 'content', '')); + + if ($content === '') { + return null; + } + + $checked = false; + + if (preg_match('/^(?:[-*+]\s+)?\[(?[xX ])\]\s*(?.+)$/u', $content, $checkboxMatches)) { + $checked = strtolower((string) Arr::get($checkboxMatches, 'state', '')) === 'x'; + $content = trim((string) Arr::get($checkboxMatches, 'text', '')); + } elseif (preg_match('/^[-*+]\s+(?.+)$/u', $content, $bulletMatches)) { + $content = trim((string) Arr::get($bulletMatches, 'text', '')); + } + + if ($content === '') { + return null; + } + + return [ + 'checked' => $checked, + 'indent' => self::indentLevel((string) Arr::get($matches, 'indent', '')), + 'text' => $content, + ]; + } + + private static function indentLevel(string $leadingWhitespace): int + { + $normalizedWhitespace = str_replace("\t", ' ', $leadingWhitespace); + + return self::clampIndent(intdiv(strlen($normalizedWhitespace), 2)); + } + + private static function clampIndent(int $indent): int + { + return max(0, min(self::MAX_INDENT_LEVEL, $indent)); + } +} diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index 6cd6f95..9b21690 100644 --- a/app/Actions/Tickets/UpdateProjectTicket.php +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -2,6 +2,7 @@ namespace App\Actions\Tickets; +use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Enums\TicketWorklogEntryType; use App\Models\Ticket; use App\Models\TicketFieldDefinition; @@ -20,7 +21,14 @@ class UpdateProjectTicket /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array{ + * summary: string|null, + * items: array + * }, * assignee_user_id?: int|null, * author_user_id?: int|null, * blocked_comment: string|null, @@ -77,6 +85,14 @@ class UpdateProjectTicket /** @var Collection $existingFieldValues */ $existingFieldValues = $ticket->fieldValues ->keyBy('ticket_field_definition_id'); + $currentAcceptanceCriteria = TicketAcceptanceCriteriaData::fromStored( + $ticket->acceptance_criteria_data, + $ticket->acceptance_criteria, + ); + $nextAcceptanceCriteria = TicketAcceptanceCriteriaData::fromStored( + $ticketAttributes['acceptance_criteria_data'], + $ticketAttributes['acceptance_criteria'], + ); $changes = []; $summaryLabels = []; @@ -97,13 +113,11 @@ class UpdateProjectTicket $ticket->description, $ticketAttributes['description'], ); - $this->trackScalarChange( + $this->trackAcceptanceCriteriaChange( $changes, $summaryLabels, - 'acceptance_criteria', - 'acceptance criteria', - $ticket->acceptance_criteria, - $ticketAttributes['acceptance_criteria'], + $currentAcceptanceCriteria, + $nextAcceptanceCriteria, ); $this->trackScalarChange( $changes, @@ -217,7 +231,14 @@ class UpdateProjectTicket /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array{ + * summary: string|null, + * items: array + * }, * assignee_user_id?: int|null, * author_user_id?: int|null, * blocked_comment: string|null, @@ -231,6 +252,14 @@ class UpdateProjectTicket * } $validated * @return array{ * acceptance_criteria: string|null, + * acceptance_criteria_data: array{ + * summary: string|null, + * items: array + * }|null, * assignee_user_id: int|null, * author_user_id: int|null, * blocked_comment: string|null, @@ -245,8 +274,13 @@ class UpdateProjectTicket */ private function buildTicketAttributes(Ticket $ticket, array $validated): array { + $acceptanceCriteria = TicketAcceptanceCriteriaData::normalize( + Arr::get($validated, 'acceptance_criteria'), + ); + return [ - 'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'), + 'acceptance_criteria' => TicketAcceptanceCriteriaData::toLegacyText($acceptanceCriteria), + 'acceptance_criteria_data' => TicketAcceptanceCriteriaData::forStorage($acceptanceCriteria), 'assignee_user_id' => Arr::exists($validated, 'assignee_user_id') ? Arr::get($validated, 'assignee_user_id') : $ticket->assignee_user_id, @@ -266,6 +300,35 @@ class UpdateProjectTicket ]; } + /** + * @param array $changes + * @param array $summaryLabels + * @param array{ + * summary: string|null, + * items: array + * } $currentValue + * @param array{ + * summary: string|null, + * items: array + * } $newValue + */ + private function trackAcceptanceCriteriaChange( + array &$changes, + array &$summaryLabels, + array $currentValue, + array $newValue, + ): void { + if ($currentValue === $newValue) { + return; + } + + $changes['acceptance_criteria'] = [ + 'from' => $currentValue, + 'to' => $newValue, + ]; + $summaryLabels[] = 'acceptance criteria'; + } + /** * @param array $changes * @param array $summaryLabels diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index be0545f..cf5e886 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -17,6 +18,18 @@ class StoreProjectTicketRequest extends FormRequest return $this->user() !== null; } + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize( + $this->input('acceptance_criteria'), + ), + ]); + } + /** * Get the validation rules that apply to the request. * @@ -28,7 +41,13 @@ class StoreProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'acceptance_criteria' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array'], + 'acceptance_criteria.summary' => ['nullable', 'string', 'max:255'], + 'acceptance_criteria.items' => ['array'], + 'acceptance_criteria.items.*' => ['array'], + 'acceptance_criteria.items.*.text' => ['required', 'string'], + 'acceptance_criteria.items.*.checked' => ['required', 'boolean'], + 'acceptance_criteria.items.*.indent' => ['required', 'integer', 'min:0', 'max:'.TicketAcceptanceCriteriaData::MAX_INDENT_LEVEL], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'description' => ['nullable', 'string'], 'ticket_priority_id' => [ diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index fe705ff..a8d08e7 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Actions\Permissions\PermissionService; +use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Enums\Permission; use App\Models\Project; use App\Models\Ticket; @@ -23,6 +24,18 @@ class UpdateProjectTicketRequest extends FormRequest return $this->user() !== null; } + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize( + $this->input('acceptance_criteria'), + ), + ]); + } + /** * Get the validation rules that apply to the request. * @@ -34,7 +47,13 @@ class UpdateProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'acceptance_criteria' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array'], + 'acceptance_criteria.summary' => ['nullable', 'string', 'max:255'], + 'acceptance_criteria.items' => ['array'], + 'acceptance_criteria.items.*' => ['array'], + 'acceptance_criteria.items.*.text' => ['required', 'string'], + 'acceptance_criteria.items.*.checked' => ['required', 'boolean'], + 'acceptance_criteria.items.*.indent' => ['required', 'integer', 'min:0', 'max:'.TicketAcceptanceCriteriaData::MAX_INDENT_LEVEL], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'blocked_comment' => ['nullable', 'string'], diff --git a/app/Http/Resources/TicketResource.php b/app/Http/Resources/TicketResource.php index ff7be1b..e4dee66 100644 --- a/app/Http/Resources/TicketResource.php +++ b/app/Http/Resources/TicketResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -16,7 +17,11 @@ class TicketResource extends JsonResource { return [ 'id' => $this->id, - 'acceptance_criteria' => $this->acceptance_criteria, + 'acceptance_criteria' => TicketAcceptanceCriteriaData::fromStored( + $this->acceptance_criteria_data, + $this->acceptance_criteria, + ), + 'acceptance_criteria_text' => $this->acceptance_criteria, 'assignee' => $this->whenLoaded( 'assignee', fn (): ?array => $this->assignee === null diff --git a/app/Mcp/Support/KanbanGitMcp.php b/app/Mcp/Support/KanbanGitMcp.php index 5f562bb..6f0acec 100644 --- a/app/Mcp/Support/KanbanGitMcp.php +++ b/app/Mcp/Support/KanbanGitMcp.php @@ -3,6 +3,7 @@ namespace App\Mcp\Support; use App\Actions\Permissions\PermissionService; +use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Enums\Permission; use App\Http\Resources\ProjectResource; use App\Http\Resources\TicketResource; @@ -207,7 +208,10 @@ class KanbanGitMcp /** * @return array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array{ + * summary: string|null, + * items: array + * }, * assignee_user_id: int|null, * author_user_id: int|null, * blocked_comment: string|null, @@ -223,7 +227,10 @@ class KanbanGitMcp public function updatePayload(Ticket $ticket): array { return [ - 'acceptance_criteria' => $ticket->acceptance_criteria, + 'acceptance_criteria' => TicketAcceptanceCriteriaData::fromStored( + $ticket->acceptance_criteria_data, + $ticket->acceptance_criteria, + ), 'assignee_user_id' => $ticket->assignee_user_id, 'author_user_id' => $ticket->author_user_id, 'blocked_comment' => $ticket->blocked_comment, diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 7c1726c..5936e56 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'acceptance_criteria', + 'acceptance_criteria_data', 'assignee_user_id', 'author_user_id', 'blocked_comment', @@ -37,6 +38,7 @@ class Ticket extends Model protected function casts(): array { return [ + 'acceptance_criteria_data' => 'array', 'is_blocked' => 'boolean', ]; } diff --git a/database/migrations/2026_04_24_203500_add_acceptance_criteria_data_to_tickets_table.php b/database/migrations/2026_04_24_203500_add_acceptance_criteria_data_to_tickets_table.php new file mode 100644 index 0000000..d74bd70 --- /dev/null +++ b/database/migrations/2026_04_24_203500_add_acceptance_criteria_data_to_tickets_table.php @@ -0,0 +1,28 @@ +json('acceptance_criteria_data')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropColumn('acceptance_criteria_data'); + }); + } +}; diff --git a/resources/js/components/TicketDetailsCard.vue b/resources/js/components/TicketDetailsCard.vue index c751d09..462bd8b 100644 --- a/resources/js/components/TicketDetailsCard.vue +++ b/resources/js/components/TicketDetailsCard.vue @@ -1,5 +1,6 @@