Simplify acceptance criteria checklist model
linter / quality (pull_request) Successful in 1m16s
tests / ci (8.4) (pull_request) Successful in 1m30s

This commit is contained in:
2026-05-19 22:44:28 +00:00
parent 7805bd057e
commit 689db9e41b
16 changed files with 140 additions and 363 deletions
+9 -12
View File
@@ -4,7 +4,7 @@ namespace App\Actions\Tickets;
use App\Actions\Notifications\BuildTicketCreatedPayload; use App\Actions\Notifications\BuildTicketCreatedPayload;
use App\Actions\Notifications\TicketCreated; use App\Actions\Notifications\TicketCreated;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
use App\Enums\TicketWorklogEntryType; use App\Enums\TicketWorklogEntryType;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -23,14 +23,11 @@ class CreateProjectTicket
/** /**
* @param array{ * @param array{
* acceptance_criteria: array{ * acceptance_criteria: array<int, array{
* summary: string|null, * checked: bool,
* items: array<int, array{ * indent: int,
* checked: bool, * text: string
* indent: int, * }>,
* text: string
* }>
* },
* assignee_user_id?: int|null, * assignee_user_id?: int|null,
* description: string|null, * description: string|null,
* story_points?: int|null, * story_points?: int|null,
@@ -43,7 +40,7 @@ class CreateProjectTicket
public function handle(Project $project, array $attributes, ?User $actor = null): Ticket public function handle(Project $project, array $attributes, ?User $actor = null): Ticket
{ {
return DB::transaction(function () use ($actor, $attributes, $project): Ticket { return DB::transaction(function () use ($actor, $attributes, $project): Ticket {
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize( $acceptanceCriteria = AcceptanceCriteriaChecklist::normalize(
Arr::get($attributes, 'acceptance_criteria'), Arr::get($attributes, 'acceptance_criteria'),
); );
$nextSequenceNumber = (int) Ticket::query() $nextSequenceNumber = (int) Ticket::query()
@@ -54,7 +51,7 @@ class CreateProjectTicket
->findOrFail(Arr::get($attributes, 'ticket_status_id')); ->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'acceptance_criteria' => TicketAcceptanceCriteriaData::toLegacyText($acceptanceCriteria), 'acceptance_criteria' => AcceptanceCriteriaChecklist::toText($acceptanceCriteria),
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
'author_user_id' => $actor?->id, 'author_user_id' => $actor?->id,
'description' => Arr::get($attributes, 'description'), 'description' => Arr::get($attributes, 'description'),
@@ -68,7 +65,7 @@ class CreateProjectTicket
'story_points' => Arr::get($attributes, 'story_points'), 'story_points' => Arr::get($attributes, 'story_points'),
]); ]);
TicketAcceptanceCriteriaData::syncTicket($ticket, $acceptanceCriteria); AcceptanceCriteriaChecklist::syncTicket($ticket, $acceptanceCriteria);
$occurredAt = CarbonImmutable::now(); $occurredAt = CarbonImmutable::now();
@@ -7,24 +7,16 @@ use App\Models\Ticket;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class TicketAcceptanceCriteriaData class AcceptanceCriteriaChecklist
{ {
public const MAX_INDENT_LEVEL = 8; public const MAX_INDENT_LEVEL = 8;
/** /**
* @param iterable<AcceptanceCriterion> $criteria * @param iterable<AcceptanceCriterion> $criteria
* @return array{ * @return array<int, array{checked: bool, indent: int, text: string}>
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/ */
public static function fromModels(iterable $criteria, ?string $legacyText): array public static function fromModels(iterable $criteria, ?string $legacyText = null): array
{ {
$summary = self::normalize($legacyText)['summary'];
$models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria)) $models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria))
->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int { ->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int {
return [ return [
@@ -43,27 +35,17 @@ class TicketAcceptanceCriteriaData
return self::normalize($legacyText); return self::normalize($legacyText);
} }
/** @var array<int|null, array<int, AcceptanceCriterion>> $grouped */ /** @var array<int|string, iterable<AcceptanceCriterion>> $grouped */
$grouped = $models->groupBy('parent_acceptance_criterion_id')->all(); $grouped = $models->groupBy('parent_acceptance_criterion_id')->all();
$items = []; $items = [];
self::flattenItems($items, $grouped, null, 0); self::flattenItems($items, $grouped, null, 0);
return [ return $items;
'summary' => $summary,
'items' => $items,
];
} }
/** /**
* @return array{ * @return array<int, array{checked: bool, indent: int, text: string}>
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/ */
public static function normalize(mixed $value): array public static function normalize(mixed $value): array
{ {
@@ -75,50 +57,27 @@ class TicketAcceptanceCriteriaData
return self::normalizeFromString($value); return self::normalizeFromString($value);
} }
return self::empty(); return [];
}
/**
* @param array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $acceptanceCriteria
*/
public static function hasContent(array $acceptanceCriteria): bool
{
return $acceptanceCriteria['summary'] !== null
|| $acceptanceCriteria['items'] !== [];
} }
/** /**
* @param Ticket $ticket * @param Ticket $ticket
* @param array{ * @param array<int, array{checked: bool, indent: int, text: string}> $items
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $acceptanceCriteria
*/ */
public static function syncTicket(Ticket $ticket, array $acceptanceCriteria): void public static function syncTicket(Ticket $ticket, array $items): void
{ {
$normalized = self::normalize($acceptanceCriteria); $normalized = self::normalize($items);
$ticket->acceptanceCriteria()->delete(); $ticket->acceptanceCriteria()->delete();
if ($normalized['items'] === []) { if ($normalized === []) {
return; return;
} }
$parentStack = []; $parentStack = [];
$positionsByParent = []; $positionsByParent = [];
foreach ($normalized['items'] as $item) { foreach ($normalized as $item) {
$indent = min($item['indent'], count($parentStack)); $indent = min($item['indent'], count($parentStack));
$parentId = $indent === 0 ? null : $parentStack[$indent - 1]; $parentId = $indent === 0 ? null : $parentStack[$indent - 1];
$positionKey = $parentId ?? 'root'; $positionKey = $parentId ?? 'root';
@@ -140,60 +99,25 @@ class TicketAcceptanceCriteriaData
} }
/** /**
* @param array{ * @param array<int, array{checked: bool, indent: int, text: string}> $items
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $acceptanceCriteria
*/ */
public static function toLegacyText(array $acceptanceCriteria): ?string public static function toText(array $items): ?string
{ {
$normalized = self::normalize($acceptanceCriteria); $normalized = self::normalize($items);
$lines = [];
if ($normalized['summary'] !== null) { if ($normalized === []) {
$lines[] = $normalized['summary']; return null;
} }
if ($normalized['items'] !== []) { return implode("\n", array_map(
if ($normalized['summary'] !== null) { static fn (array $item): string => sprintf(
$lines[] = ''; '%s- [%s] %s',
} str_repeat(' ', $item['indent']),
$item['checked'] ? 'x' : ' ',
foreach ($normalized['items'] as $item) { $item['text'],
$lines[] = sprintf( ),
'%s- [%s] %s', $normalized,
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<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
private static function empty(): array
{
return [
'summary' => null,
'items' => [],
];
} }
/** /**
@@ -214,23 +138,15 @@ class TicketAcceptanceCriteriaData
} }
/** /**
* @param array<string, mixed> $value * @param array<int|string, mixed> $value
* @return array{ * @return array<int, array{checked: bool, indent: int, text: string}>
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/ */
private static function normalizeFromArray(array $value): array private static function normalizeFromArray(array $value): array
{ {
$summary = trim((string) Arr::get($value, 'summary', '')); $rawItems = Arr::isAssoc($value) ? Arr::get($value, 'items', []) : $value;
$rawItems = Arr::get($value, 'items', []);
if (! is_array($rawItems)) { if (! is_array($rawItems)) {
$rawItems = []; return [];
} }
$items = []; $items = [];
@@ -246,78 +162,37 @@ class TicketAcceptanceCriteriaData
continue; continue;
} }
$rawChecked = Arr::get($rawItem, 'checked', false);
$rawIndent = Arr::get($rawItem, 'indent', 0);
$items[] = [ $items[] = [
'checked' => filter_var($rawChecked, FILTER_VALIDATE_BOOLEAN), 'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN),
'indent' => self::clampIndent((int) $rawIndent), 'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)),
'text' => $text, 'text' => $text,
]; ];
} }
return [ return $items;
'summary' => $summary === '' ? null : $summary,
'items' => $items,
];
} }
/** /**
* @return array{ * @return array<int, array{checked: bool, indent: int, text: string}>
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/ */
private static function normalizeFromString(?string $value): array private static function normalizeFromString(?string $value): array
{ {
$text = trim((string) $value); $text = trim((string) $value);
if ($text === '') { if ($text === '') {
return self::empty(); return [];
} }
$lines = preg_split('/\R/u', $text); $lines = preg_split('/\R/u', $text);
if (! is_array($lines)) { if (! is_array($lines)) {
return [ return [];
'summary' => $text,
'items' => [],
];
} }
$summary = null; return array_values(array_filter(array_map(
$items = []; static fn (string $line): ?array => self::parseChecklistItem($line),
$lines,
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,
];
} }
/** /**
@@ -348,6 +223,8 @@ class TicketAcceptanceCriteriaData
$content = trim((string) Arr::get($checkboxMatches, 'text', '')); $content = trim((string) Arr::get($checkboxMatches, 'text', ''));
} elseif (preg_match('/^[-*+]\s+(?<text>.+)$/u', $content, $bulletMatches)) { } elseif (preg_match('/^[-*+]\s+(?<text>.+)$/u', $content, $bulletMatches)) {
$content = trim((string) Arr::get($bulletMatches, 'text', '')); $content = trim((string) Arr::get($bulletMatches, 'text', ''));
} else {
return null;
} }
if ($content === '') { if ($content === '') {
+18 -30
View File
@@ -2,7 +2,7 @@
namespace App\Actions\Tickets; namespace App\Actions\Tickets;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
use App\Enums\TicketWorklogEntryType; use App\Enums\TicketWorklogEntryType;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketFieldDefinition; use App\Models\TicketFieldDefinition;
@@ -21,14 +21,11 @@ class UpdateProjectTicket
/** /**
* @param array{ * @param array{
* acceptance_criteria: array{ * acceptance_criteria: array<int, array{
* summary: string|null, * checked: bool,
* items: array<int, array{ * indent: int,
* checked: bool, * text: string
* indent: int, * }>,
* text: string
* }>
* },
* assignee_user_id?: int|null, * assignee_user_id?: int|null,
* author_user_id?: int|null, * author_user_id?: int|null,
* blocked_comment: string|null, * blocked_comment: string|null,
@@ -52,7 +49,7 @@ class UpdateProjectTicket
array $validated, array $validated,
): bool { ): bool {
$fieldValueAttributes = Arr::get($validated, 'field_values', []); $fieldValueAttributes = Arr::get($validated, 'field_values', []);
$nextAcceptanceCriteria = TicketAcceptanceCriteriaData::normalize( $nextAcceptanceCriteria = AcceptanceCriteriaChecklist::normalize(
Arr::get($validated, 'acceptance_criteria'), Arr::get($validated, 'acceptance_criteria'),
); );
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated); $ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
@@ -89,7 +86,7 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $existingFieldValues */ /** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues $existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id'); ->keyBy('ticket_field_definition_id');
$currentAcceptanceCriteria = TicketAcceptanceCriteriaData::fromModels( $currentAcceptanceCriteria = AcceptanceCriteriaChecklist::fromModels(
$ticket->acceptanceCriteria, $ticket->acceptanceCriteria,
$ticket->acceptance_criteria, $ticket->acceptance_criteria,
); );
@@ -199,7 +196,7 @@ class UpdateProjectTicket
} }
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) { if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
TicketAcceptanceCriteriaData::syncTicket($ticket, $nextAcceptanceCriteria); AcceptanceCriteriaChecklist::syncTicket($ticket, $nextAcceptanceCriteria);
} }
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle( $fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
@@ -235,14 +232,11 @@ class UpdateProjectTicket
/** /**
* @param array{ * @param array{
* acceptance_criteria: array{ * acceptance_criteria: array<int, array{
* summary: string|null, * checked: bool,
* items: array<int, array{ * indent: int,
* checked: bool, * text: string
* indent: int, * }>,
* text: string
* }>
* },
* assignee_user_id?: int|null, * assignee_user_id?: int|null,
* author_user_id?: int|null, * author_user_id?: int|null,
* blocked_comment: string|null, * blocked_comment: string|null,
@@ -270,12 +264,12 @@ class UpdateProjectTicket
*/ */
private function buildTicketAttributes(Ticket $ticket, array $validated): array private function buildTicketAttributes(Ticket $ticket, array $validated): array
{ {
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize( $acceptanceCriteria = AcceptanceCriteriaChecklist::normalize(
Arr::get($validated, 'acceptance_criteria'), Arr::get($validated, 'acceptance_criteria'),
); );
return [ return [
'acceptance_criteria' => TicketAcceptanceCriteriaData::toLegacyText($acceptanceCriteria), 'acceptance_criteria' => AcceptanceCriteriaChecklist::toText($acceptanceCriteria),
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id') 'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
? Arr::get($validated, 'assignee_user_id') ? Arr::get($validated, 'assignee_user_id')
: $ticket->assignee_user_id, : $ticket->assignee_user_id,
@@ -298,14 +292,8 @@ class UpdateProjectTicket
/** /**
* @param array<string, mixed> $changes * @param array<string, mixed> $changes
* @param array<int, string> $summaryLabels * @param array<int, string> $summaryLabels
* @param array{ * @param array<int, array{checked: bool, indent: int, text: string}> $currentValue
* summary: string|null, * @param array<int, array{checked: bool, indent: int, text: string}> $newValue
* items: array<int, array{checked: bool, indent: int, text: string}>
* } $currentValue
* @param array{
* summary: string|null,
* items: array<int, array{checked: bool, indent: int, text: string}>
* } $newValue
*/ */
private function trackAcceptanceCriteriaChange( private function trackAcceptanceCriteriaChange(
array &$changes, array &$changes,
@@ -2,7 +2,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
use App\Models\Project; use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
@@ -24,7 +24,7 @@ class StoreProjectTicketRequest extends FormRequest
protected function prepareForValidation(): void protected function prepareForValidation(): void
{ {
$this->merge([ $this->merge([
'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize( 'acceptance_criteria' => AcceptanceCriteriaChecklist::normalize(
$this->input('acceptance_criteria'), $this->input('acceptance_criteria'),
), ),
]); ]);
@@ -42,12 +42,10 @@ class StoreProjectTicketRequest extends FormRequest
return [ return [
'acceptance_criteria' => ['nullable', 'array'], 'acceptance_criteria' => ['nullable', 'array'],
'acceptance_criteria.summary' => ['nullable', 'string', 'max:255'], 'acceptance_criteria.*' => ['array'],
'acceptance_criteria.items' => ['array'], 'acceptance_criteria.*.text' => ['required', 'string'],
'acceptance_criteria.items.*' => ['array'], 'acceptance_criteria.*.checked' => ['required', 'boolean'],
'acceptance_criteria.items.*.text' => ['required', 'string'], 'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklist::MAX_INDENT_LEVEL],
'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')], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'ticket_priority_id' => [ 'ticket_priority_id' => [
@@ -3,7 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -30,7 +30,7 @@ class UpdateProjectTicketRequest extends FormRequest
protected function prepareForValidation(): void protected function prepareForValidation(): void
{ {
$this->merge([ $this->merge([
'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize( 'acceptance_criteria' => AcceptanceCriteriaChecklist::normalize(
$this->input('acceptance_criteria'), $this->input('acceptance_criteria'),
), ),
]); ]);
@@ -48,12 +48,10 @@ class UpdateProjectTicketRequest extends FormRequest
return [ return [
'acceptance_criteria' => ['nullable', 'array'], 'acceptance_criteria' => ['nullable', 'array'],
'acceptance_criteria.summary' => ['nullable', 'string', 'max:255'], 'acceptance_criteria.*' => ['array'],
'acceptance_criteria.items' => ['array'], 'acceptance_criteria.*.text' => ['required', 'string'],
'acceptance_criteria.items.*' => ['array'], 'acceptance_criteria.*.checked' => ['required', 'boolean'],
'acceptance_criteria.items.*.text' => ['required', 'string'], 'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklist::MAX_INDENT_LEVEL],
'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')], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'blocked_comment' => ['nullable', 'string'], 'blocked_comment' => ['nullable', 'string'],
+2 -3
View File
@@ -2,7 +2,7 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,13 +17,12 @@ class TicketResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromModels( 'acceptance_criteria' => AcceptanceCriteriaChecklist::fromModels(
$this->relationLoaded('acceptanceCriteria') $this->relationLoaded('acceptanceCriteria')
? $this->acceptanceCriteria ? $this->acceptanceCriteria
: $this->acceptanceCriteria()->get(), : $this->acceptanceCriteria()->get(),
$this->acceptance_criteria, $this->acceptance_criteria,
), ),
'acceptance_criteria_text' => $this->acceptance_criteria,
'assignee' => $this->whenLoaded( 'assignee' => $this->whenLoaded(
'assignee', 'assignee',
fn (): ?array => $this->assignee === null fn (): ?array => $this->assignee === null
+3 -6
View File
@@ -3,7 +3,7 @@
namespace App\Mcp\Support; namespace App\Mcp\Support;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
use App\Enums\Permission; use App\Enums\Permission;
use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectResource;
use App\Http\Resources\TicketResource; use App\Http\Resources\TicketResource;
@@ -208,10 +208,7 @@ class KanbanGitMcp
/** /**
* @return array{ * @return array{
* acceptance_criteria: array{ * acceptance_criteria: array<int, array{checked: bool, indent: int, text: string}>,
* summary: string|null,
* items: array<int, array{checked: bool, indent: int, text: string}>
* },
* assignee_user_id: int|null, * assignee_user_id: int|null,
* author_user_id: int|null, * author_user_id: int|null,
* blocked_comment: string|null, * blocked_comment: string|null,
@@ -227,7 +224,7 @@ class KanbanGitMcp
public function updatePayload(Ticket $ticket): array public function updatePayload(Ticket $ticket): array
{ {
return [ return [
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromModels( 'acceptance_criteria' => AcceptanceCriteriaChecklist::fromModels(
$ticket->relationLoaded('acceptanceCriteria') $ticket->relationLoaded('acceptanceCriteria')
? $ticket->acceptanceCriteria ? $ticket->acceptanceCriteria
: $ticket->acceptanceCriteria()->get(), : $ticket->acceptanceCriteria()->get(),
+8 -25
View File
@@ -7,24 +7,15 @@ const MAX_INDENT_LEVEL = 8;
export type AcceptanceCriteriaEditorState = { export type AcceptanceCriteriaEditorState = {
checklist: string; checklist: string;
summary: string;
}; };
export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => ({ export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => [];
summary: null,
items: [],
});
export const normalizeAcceptanceCriteria = ( export const normalizeAcceptanceCriteria = (
value: AcceptanceCriteriaModel | null | undefined, value: AcceptanceCriteriaModel | null | undefined,
): AcceptanceCriteriaModel => { ): AcceptanceCriteriaModel => {
if (value === null || value === undefined) { const items = Array.isArray(value)
return emptyAcceptanceCriteria(); ? value
}
const summary = value.summary?.trim() ?? null;
const items = Array.isArray(value.items)
? value.items
.map((item) => ({ .map((item) => ({
checked: item.checked === true, checked: item.checked === true,
indent: clampIndent(item.indent), indent: clampIndent(item.indent),
@@ -33,10 +24,7 @@ export const normalizeAcceptanceCriteria = (
.filter((item) => item.text !== '') .filter((item) => item.text !== '')
: []; : [];
return { return items;
summary: summary === '' ? null : summary,
items,
};
}; };
export const parseChecklistLines = ( export const parseChecklistLines = (
@@ -69,15 +57,9 @@ export const formatChecklistLines = (
}; };
export const buildAcceptanceCriteria = ( export const buildAcceptanceCriteria = (
summaryInput: string,
checklistInput: string, checklistInput: string,
): AcceptanceCriteriaModel => { ): AcceptanceCriteriaModel => {
const summary = summaryInput.trim(); return normalizeAcceptanceCriteria(parseChecklistLines(checklistInput));
return normalizeAcceptanceCriteria({
summary: summary === '' ? null : summary,
items: parseChecklistLines(checklistInput),
});
}; };
export const acceptanceCriteriaToEditorState = ( export const acceptanceCriteriaToEditorState = (
@@ -86,8 +68,7 @@ export const acceptanceCriteriaToEditorState = (
const normalized = normalizeAcceptanceCriteria(value); const normalized = normalizeAcceptanceCriteria(value);
return { return {
checklist: formatChecklistLines(normalized.items), checklist: formatChecklistLines(normalized),
summary: normalized.summary ?? '',
}; };
}; };
@@ -123,6 +104,8 @@ const parseChecklistLine = (
if (bulletMatch?.groups !== undefined) { if (bulletMatch?.groups !== undefined) {
content = bulletMatch.groups.text.trim(); content = bulletMatch.groups.text.trim();
} else {
return null;
} }
} }
+3 -26
View File
@@ -111,14 +111,10 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState( const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria, props.ticket.acceptance_criteria,
); );
const acceptanceCriteriaSummary = ref(acceptanceCriteriaEditorState.summary);
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist); const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
const form = useForm({ const form = useForm({
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '', assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '', author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '', blocked_comment: props.ticket.blocked_comment ?? '',
@@ -151,10 +147,8 @@ const acceptanceCriteriaPreviewItems = computed(() =>
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
form.errors.acceptance_criteria ?? form.errors.acceptance_criteria ??
form.errors['acceptance_criteria.summary'] ??
form.errors['acceptance_criteria.items'] ??
Object.entries(form.errors).find(([key]) => Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.items.'), key.startsWith('acceptance_criteria.'),
)?.[1] ?? )?.[1] ??
'' ''
); );
@@ -348,10 +342,7 @@ const submit = (): void => {
return; return;
} }
form.acceptance_criteria = buildAcceptanceCriteria( form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
);
form.submit( form.submit(
ProjectTicketController.update({ ProjectTicketController.update({
@@ -625,20 +616,6 @@ const formatDateTime = (value: string): string => {
</div> </div>
<div class="grid gap-4"> <div class="grid gap-4">
<div class="grid gap-2">
<Label for="acceptance_criteria_summary">
Acceptance criteria summary
</Label>
<Input
id="acceptance_criteria_summary"
v-model="acceptanceCriteriaSummary"
:disabled="
!canEditTicket || form.processing
"
placeholder="What should be true when this ticket is done?"
/>
</div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="acceptance_criteria_checklist"> <Label for="acceptance_criteria_checklist">
Acceptance criteria checklist Acceptance criteria checklist
+3 -24
View File
@@ -61,14 +61,10 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 && props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketStatusOptions.length > 0;
const acceptanceCriteriaSummary = ref('');
const acceptanceCriteriaChecklist = ref(''); const acceptanceCriteriaChecklist = ref('');
const form = useForm({ const form = useForm({
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '', assignee_user_id: '',
description: '', description: '',
story_points: '', story_points: '',
@@ -87,20 +83,15 @@ const acceptanceCriteriaPreviewItems = computed(() =>
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
form.errors.acceptance_criteria ?? form.errors.acceptance_criteria ??
form.errors['acceptance_criteria.summary'] ??
form.errors['acceptance_criteria.items'] ??
Object.entries(form.errors).find(([key]) => Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.items.'), key.startsWith('acceptance_criteria.'),
)?.[1] ?? )?.[1] ??
'' ''
); );
}); });
const submit = (): void => { const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria( form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
);
form.submit(ProjectTicketController.store({ project: props.project.id }), { form.submit(ProjectTicketController.store({ project: props.project.id }), {
preserveScroll: true, preserveScroll: true,
@@ -197,18 +188,6 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
</div> </div>
<div class="grid gap-4"> <div class="grid gap-4">
<div class="grid gap-2">
<Label for="acceptance_criteria_summary">
Acceptance criteria summary
</Label>
<Input
id="acceptance_criteria_summary"
v-model="acceptanceCriteriaSummary"
:disabled="form.processing"
placeholder="What should be true when this ticket is done?"
/>
</div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="acceptance_criteria_checklist"> <Label for="acceptance_criteria_checklist">
Acceptance criteria checklist Acceptance criteria checklist
@@ -4,7 +4,4 @@ export type AcceptanceCriteriaItemModel = {
text: string; text: string;
}; };
export type AcceptanceCriteriaModel = { export type AcceptanceCriteriaModel = AcceptanceCriteriaItemModel[];
summary: string | null;
items: AcceptanceCriteriaItemModel[];
};
@@ -9,7 +9,6 @@ import type { UserModel } from '@/types/models/user-model';
export type TicketModel = { export type TicketModel = {
id: number; id: number;
acceptance_criteria: AcceptanceCriteriaModel; acceptance_criteria: AcceptanceCriteriaModel;
acceptance_criteria_text: string | null;
assignee: UserModel | null; assignee: UserModel | null;
assignee_user_id: number | null; assignee_user_id: number | null;
author: UserModel | null; author: UserModel | null;
+15 -22
View File
@@ -44,18 +44,15 @@ class TicketApiTest extends TestCase
$createResponse = $this->withApplicationKey($user) $createResponse = $this->withApplicationKey($user)
->postJson(route('api.projects.tickets.store', $project), [ ->postJson(route('api.projects.tickets.store', $project), [
'acceptance_criteria' => [ 'acceptance_criteria' => [
'summary' => 'Can sign in', [
'items' => [ 'checked' => false,
[ 'indent' => 0,
'checked' => false, 'text' => 'Credentials are validated',
'indent' => 0, ],
'text' => 'Credentials are validated', [
], 'checked' => true,
[ 'indent' => 1,
'checked' => true, 'text' => 'Nested validation is supported',
'indent' => 1,
'text' => 'Nested validation is supported',
],
], ],
], ],
'description' => 'Build the API-created ticket', 'description' => 'Build the API-created ticket',
@@ -67,8 +64,7 @@ class TicketApiTest extends TestCase
]) ])
->assertCreated() ->assertCreated()
->assertJsonPath('data.ticket_key', 'CORE-1') ->assertJsonPath('data.ticket_key', 'CORE-1')
->assertJsonPath('data.acceptance_criteria.summary', 'Can sign in') ->assertJsonPath('data.acceptance_criteria.1.indent', 1)
->assertJsonPath('data.acceptance_criteria.items.1.indent', 1)
->assertJsonPath('meta.message', 'CORE-1 was created.'); ->assertJsonPath('meta.message', 'CORE-1 was created.');
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id')); $ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
@@ -81,13 +77,10 @@ class TicketApiTest extends TestCase
$this->withApplicationKey($user) $this->withApplicationKey($user)
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
'acceptance_criteria' => [ 'acceptance_criteria' => [
'summary' => 'Can sign in from the API', [
'items' => [ 'checked' => true,
[ 'indent' => 0,
'checked' => true, 'text' => 'API update preserves checked state',
'indent' => 0,
'text' => 'API update preserves checked state',
],
], ],
], ],
'blocked_comment' => null, 'blocked_comment' => null,
@@ -101,7 +94,7 @@ class TicketApiTest extends TestCase
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.title', 'Updated API ticket') ->assertJsonPath('data.title', 'Updated API ticket')
->assertJsonPath('data.acceptance_criteria.items.0.checked', true) ->assertJsonPath('data.acceptance_criteria.0.checked', true)
->assertJsonPath('meta.message', 'CORE-1 was updated.'); ->assertJsonPath('meta.message', 'CORE-1 was updated.');
$this->withApplicationKey($user) $this->withApplicationKey($user)
@@ -110,7 +110,7 @@ class TicketCreatedNotificationDispatchTest extends TestCase
$response = $this->actingAs($user) $response = $this->actingAs($user)
->post(route('projects.tickets.store', $project), [ ->post(route('projects.tickets.store', $project), [
'acceptance_criteria' => 'Ship it.', 'acceptance_criteria' => '- [ ] Ship it.',
'assignee_user_id' => null, 'assignee_user_id' => null,
'description' => 'Created from notification dispatch test.', 'description' => 'Created from notification dispatch test.',
'story_points' => 3, 'story_points' => 3,
@@ -84,7 +84,7 @@ class ProjectBacklogTest extends TestCase
->post(route('projects.tickets.store', $project), [ ->post(route('projects.tickets.store', $project), [
'title' => 'New ticket', 'title' => 'New ticket',
'description' => 'Description', 'description' => 'Description',
'acceptance_criteria' => 'Criteria', 'acceptance_criteria' => '- [ ] Criteria',
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id, 'ticket_status_id' => $backlogStatus->id,
@@ -118,7 +118,7 @@ class ProjectBacklogTest extends TestCase
$payload = [ $payload = [
'title' => 'Same title', 'title' => 'Same title',
'description' => 'Description', 'description' => 'Description',
'acceptance_criteria' => 'Criteria', 'acceptance_criteria' => '- [ ] Criteria',
'assignee_user_id' => $assignee->id, 'assignee_user_id' => $assignee->id,
'story_points' => 3, 'story_points' => 3,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
@@ -192,7 +192,7 @@ class ProjectBacklogTest extends TestCase
->post(route('projects.tickets.store', $project), [ ->post(route('projects.tickets.store', $project), [
'title' => 'New ticket', 'title' => 'New ticket',
'description' => 'Description', 'description' => 'Description',
'acceptance_criteria' => 'Criteria', 'acceptance_criteria' => '- [ ] Criteria',
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $boardStatus->id, 'ticket_status_id' => $boardStatus->id,
@@ -56,7 +56,7 @@ class ProjectTicketControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
$legacyAcceptanceCriteria = "Review checklist\n- [ ] User can sign in\n - [x] Nested verification"; $legacyAcceptanceCriteria = "- [ ] User can sign in\n - [x] Nested verification";
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'acceptance_criteria' => $legacyAcceptanceCriteria, 'acceptance_criteria' => $legacyAcceptanceCriteria,
@@ -110,14 +110,12 @@ class ProjectTicketControllerTest extends TestCase
->where('project.show_author', false) ->where('project.show_author', false)
->where('ticket.ticket_key', 'CORE-1') ->where('ticket.ticket_key', 'CORE-1')
->where('ticket.story_points', 5) ->where('ticket.story_points', 5)
->where('ticket.acceptance_criteria.summary', 'Review checklist') ->where('ticket.acceptance_criteria.0.text', 'User can sign in')
->where('ticket.acceptance_criteria.items.0.text', 'User can sign in') ->where('ticket.acceptance_criteria.0.checked', false)
->where('ticket.acceptance_criteria.items.0.checked', false) ->where('ticket.acceptance_criteria.0.indent', 0)
->where('ticket.acceptance_criteria.items.0.indent', 0) ->where('ticket.acceptance_criteria.1.text', 'Nested verification')
->where('ticket.acceptance_criteria.items.1.text', 'Nested verification') ->where('ticket.acceptance_criteria.1.checked', true)
->where('ticket.acceptance_criteria.items.1.checked', true) ->where('ticket.acceptance_criteria.1.indent', 1)
->where('ticket.acceptance_criteria.items.1.indent', 1)
->where('ticket.acceptance_criteria_text', $legacyAcceptanceCriteria)
->where('ticket.assignee.name', 'Assigned User') ->where('ticket.assignee.name', 'Assigned User')
->where('ticket.author', null) ->where('ticket.author', null)
->where('ticket.author_user_id', null) ->where('ticket.author_user_id', null)
@@ -350,7 +348,7 @@ class ProjectTicketControllerTest extends TestCase
$this->actingAs($editor) $this->actingAs($editor)
->post(route('projects.tickets.store', $project), [ ->post(route('projects.tickets.store', $project), [
'acceptance_criteria' => 'The ticket exists.', 'acceptance_criteria' => '- [ ] The ticket exists.',
'assignee_user_id' => $assignee->id, 'assignee_user_id' => $assignee->id,
'description' => 'Created from the dedicated page.', 'description' => 'Created from the dedicated page.',
'story_points' => 5, 'story_points' => 5,
@@ -448,7 +446,7 @@ class ProjectTicketControllerTest extends TestCase
]); ]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'acceptance_criteria' => 'Old acceptance criteria', 'acceptance_criteria' => '- [ ] Old acceptance criteria',
'author_user_id' => $author->id, 'author_user_id' => $author->id,
'description' => 'Old description', 'description' => 'Old description',
'project_id' => $project->id, 'project_id' => $project->id,
@@ -475,18 +473,15 @@ class ProjectTicketControllerTest extends TestCase
$this->syncProjectPermissions($project, $editor, ['write', 'view']); $this->syncProjectPermissions($project, $editor, ['write', 'view']);
$updatedAcceptanceCriteria = [ $updatedAcceptanceCriteria = [
'summary' => 'Updated acceptance criteria', [
'items' => [ 'text' => 'Shows success banner',
[ 'checked' => false,
'text' => 'Shows success banner', 'indent' => 0,
'checked' => false, ],
'indent' => 0, [
], 'text' => 'Includes nested confirmations',
[ 'checked' => true,
'text' => 'Includes nested confirmations', 'indent' => 1,
'checked' => true,
'indent' => 1,
],
], ],
]; ];
@@ -520,7 +515,7 @@ class ProjectTicketControllerTest extends TestCase
'id' => $ticket->id, 'id' => $ticket->id,
'title' => 'Updated title', 'title' => 'Updated title',
'description' => 'Updated description', 'description' => 'Updated description',
'acceptance_criteria' => "Updated acceptance criteria\n\n- [ ] Shows success banner\n - [x] Includes nested confirmations", 'acceptance_criteria' => "- [ ] Shows success banner\n - [x] Includes nested confirmations",
'assignee_user_id' => $assignee->id, 'assignee_user_id' => $assignee->id,
'story_points' => 8, 'story_points' => 8,
'ticket_type_id' => $this->parentType($project)->id, 'ticket_type_id' => $this->parentType($project)->id,
@@ -531,7 +526,7 @@ class ProjectTicketControllerTest extends TestCase
$this->assertEquals( $this->assertEquals(
$updatedAcceptanceCriteria, $updatedAcceptanceCriteria,
\App\Actions\Tickets\Support\TicketAcceptanceCriteriaData::fromModels( \App\Actions\Tickets\Support\AcceptanceCriteriaChecklist::fromModels(
$ticket->acceptanceCriteria, $ticket->acceptanceCriteria,
$ticket->acceptance_criteria, $ticket->acceptance_criteria,
), ),