Merge origin/main into koda/recreate-68-assignee-changed-notification
linter / quality (pull_request) Successful in 1m32s
tests / ci (8.4) (pull_request) Successful in 1m41s

This commit is contained in:
2026-05-20 09:35:43 +00:00
25 changed files with 767 additions and 565 deletions
+9 -12
View File
@@ -4,7 +4,7 @@ namespace App\Actions\Tickets;
use App\Actions\Notifications\BuildTicketCreatedPayload;
use App\Actions\Notifications\TicketCreated;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\TicketWorklogEntryType;
use App\Models\Project;
use App\Models\Ticket;
@@ -23,14 +23,11 @@ class CreateProjectTicket
/**
* @param array{
* acceptance_criteria: array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* },
* acceptance_criteria: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>,
* assignee_user_id?: int|null,
* description: string|null,
* story_points?: int|null,
@@ -43,7 +40,7 @@ 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(
$acceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
Arr::get($attributes, 'acceptance_criteria'),
);
$nextSequenceNumber = (int) Ticket::query()
@@ -54,8 +51,6 @@ class CreateProjectTicket
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticket = Ticket::query()->create([
'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'),
@@ -69,6 +64,8 @@ class CreateProjectTicket
'story_points' => Arr::get($attributes, 'story_points'),
]);
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
$occurredAt = CarbonImmutable::now();
if ($actor !== null) {
@@ -0,0 +1,225 @@
<?php
namespace App\Actions\Tickets\Support;
use App\Models\AcceptanceCriterion;
use App\Models\Ticket;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Arr;
class AcceptanceCriteriaChecklistMapper
{
public const MAX_INDENT_LEVEL = 8;
/**
* @param iterable<AcceptanceCriterion> $criteria
* @return array<int, array{checked: bool, indent: int, text: string}>
*/
public static function fromModels(iterable $criteria): array
{
return self::flattenModels($criteria, false);
}
/**
* @param iterable<AcceptanceCriterion> $criteria
* @return array<int, array{id: int, checked: bool, indent: int, text: string}>
*/
public static function fromModelsWithIds(iterable $criteria): array
{
return self::flattenModels($criteria, true);
}
/**
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
*/
public static function normalize(mixed $value): array
{
if (is_array($value)) {
return self::normalizeFromArray($value);
}
return [];
}
/**
* @param Ticket $ticket
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
*/
public static function syncTicket(Ticket $ticket, array $items): void
{
$normalized = self::normalize($items);
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
if ($normalized === []) {
$ticket->acceptanceCriteria()->delete();
$ticket->unsetRelation('acceptanceCriteria');
return;
}
$keptCriterionIds = [];
$parentStack = [];
$positionsByParent = [];
$fallbackCriteria = $existingCriteria->values();
$fallbackIndex = 0;
foreach ($normalized as $item) {
$indent = min($item['indent'], count($parentStack));
$parentId = $indent === 0 ? null : $parentStack[$indent - 1];
$positionKey = $parentId ?? 'root';
$position = $positionsByParent[$positionKey] ?? 0;
$criterionId = $item['id'] ?? null;
$criterion = $criterionId === null
? null
: $existingCriteria->get($criterionId);
while (
$criterion === null
&& $fallbackIndex < $fallbackCriteria->count()
) {
/** @var AcceptanceCriterion $candidate */
$candidate = $fallbackCriteria[$fallbackIndex];
$fallbackIndex++;
if (in_array($candidate->id, $keptCriterionIds, true)) {
continue;
}
$criterion = $candidate;
}
if ($criterion === null) {
$criterion = $ticket->acceptanceCriteria()->create([
'parent_acceptance_criterion_id' => $parentId,
'position' => $position,
'text' => $item['text'],
'is_checked' => $item['checked'],
]);
} else {
$criterion->fill([
'parent_acceptance_criterion_id' => $parentId,
'position' => $position,
'text' => $item['text'],
'is_checked' => $item['checked'],
]);
if ($criterion->isDirty()) {
$criterion->save();
}
}
$keptCriterionIds[] = $criterion->id;
$positionsByParent[$positionKey] = $position + 1;
$parentStack[$indent] = $criterion->id;
$parentStack = array_slice($parentStack, 0, $indent + 1);
}
$idsToDelete = $existingCriteria->keys()->diff($keptCriterionIds);
if ($idsToDelete->isNotEmpty()) {
$ticket->acceptanceCriteria()
->whereIn('id', $idsToDelete->all())
->delete();
}
$ticket->unsetRelation('acceptanceCriteria');
}
/**
* @param array<int|string, mixed> $value
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
*/
private static function normalizeFromArray(array $value): array
{
$items = [];
foreach ($value as $rawItem) {
if (! is_array($rawItem)) {
continue;
}
$text = trim((string) Arr::get($rawItem, 'text', ''));
if ($text === '') {
continue;
}
$normalizedItem = [
'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN),
'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)),
'text' => $text,
];
$id = filter_var(Arr::get($rawItem, 'id'), FILTER_VALIDATE_INT);
if (is_int($id) && $id > 0) {
$normalizedItem['id'] = $id;
}
$items[] = $normalizedItem;
}
return $items;
}
private static function clampIndent(int $indent): int
{
return max(0, min(self::MAX_INDENT_LEVEL, $indent));
}
/**
* @param iterable<AcceptanceCriterion> $criteria
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
*/
private static function flattenModels(iterable $criteria, bool $includeIds): array
{
$models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria))
->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int {
return [
$left->parent_acceptance_criterion_id ?? 0,
$left->position,
$left->id,
] <=> [
$right->parent_acceptance_criterion_id ?? 0,
$right->position,
$right->id,
];
})
->values();
if ($models->isEmpty()) {
return [];
}
/** @var array<int|string, iterable<AcceptanceCriterion>> $grouped */
$grouped = $models->groupBy('parent_acceptance_criterion_id')->all();
$items = [];
self::flattenItems($items, $grouped, null, 0, $includeIds);
return $items;
}
/**
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
* @param array<int|string, iterable<AcceptanceCriterion>> $grouped
*/
private static function flattenItems(array &$items, array $grouped, ?int $parentId, int $indent, bool $includeIds): void
{
foreach ($grouped[$parentId] ?? [] as $criterion) {
$item = [
'checked' => $criterion->is_checked,
'indent' => $indent,
'text' => $criterion->text,
];
if ($includeIds) {
$item['id'] = $criterion->id;
}
$items[] = $item;
self::flattenItems($items, $grouped, $criterion->id, $indent + 1, $includeIds);
}
}
}
@@ -1,316 +0,0 @@
<?php
namespace App\Actions\Tickets\Support;
use Illuminate\Support\Arr;
class TicketAcceptanceCriteriaData
{
public const MAX_INDENT_LEVEL = 8;
/**
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
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<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
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<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $acceptanceCriteria
*/
public static function hasContent(array $acceptanceCriteria): bool
{
return $acceptanceCriteria['summary'] !== null
|| $acceptanceCriteria['items'] !== [];
}
/**
* @param array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $acceptanceCriteria
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }|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<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $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<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
private static function empty(): array
{
return [
'summary' => null,
'items' => [],
];
}
/**
* @param array<string, mixed> $value
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
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<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
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('/^(?<indent>\s*)(?<content>.+)$/u', $line, $matches)) {
return null;
}
$content = trim((string) Arr::get($matches, 'content', ''));
if ($content === '') {
return null;
}
$checked = false;
if (preg_match('/^(?:[-*+]\s+)?\[(?<state>[xX ])\]\s*(?<text>.+)$/u', $content, $checkboxMatches)) {
$checked = strtolower((string) Arr::get($checkboxMatches, 'state', '')) === 'x';
$content = trim((string) Arr::get($checkboxMatches, 'text', ''));
} elseif (preg_match('/^[-*+]\s+(?<text>.+)$/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));
}
}
+23 -49
View File
@@ -4,7 +4,7 @@ namespace App\Actions\Tickets;
use App\Actions\Notifications\BuildTicketAssigneeChangedPayload;
use App\Actions\Notifications\TicketAssigneeChanged;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\TicketWorklogEntryType;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
@@ -25,14 +25,11 @@ class UpdateProjectTicket
/**
* @param array{
* acceptance_criteria: array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* },
* acceptance_criteria: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>,
* assignee_user_id?: int|null,
* author_user_id?: int|null,
* blocked_comment: string|null,
@@ -56,9 +53,12 @@ class UpdateProjectTicket
array $validated,
): bool {
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
Arr::get($validated, 'acceptance_criteria'),
);
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
return DB::transaction(function () use ($actor, $fieldValueAttributes, $ticket, $ticketAttributes): bool {
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
if (! $ticketAttributes['is_blocked']) {
$ticketAttributes['blocked_comment'] = null;
}
@@ -66,6 +66,7 @@ class UpdateProjectTicket
$ticket->loadMissing([
'assignee',
'author',
'acceptanceCriteria',
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
@@ -90,14 +91,7 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $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'],
);
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$changes = [];
$summaryLabels = [];
@@ -214,6 +208,10 @@ class UpdateProjectTicket
$ticket->save();
}
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
}
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
$ticket,
$fieldDefinitions,
@@ -262,14 +260,11 @@ class UpdateProjectTicket
/**
* @param array{
* acceptance_criteria: array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* },
* acceptance_criteria: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>,
* assignee_user_id?: int|null,
* author_user_id?: int|null,
* blocked_comment: string|null,
@@ -282,15 +277,6 @@ class UpdateProjectTicket
* title: string
* } $validated
* @return array{
* acceptance_criteria: string|null,
* acceptance_criteria_data: array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }|null,
* assignee_user_id: int|null,
* author_user_id: int|null,
* blocked_comment: string|null,
@@ -305,13 +291,7 @@ class UpdateProjectTicket
*/
private function buildTicketAttributes(Ticket $ticket, array $validated): array
{
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize(
Arr::get($validated, 'acceptance_criteria'),
);
return [
'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,
@@ -334,14 +314,8 @@ class UpdateProjectTicket
/**
* @param array<string, mixed> $changes
* @param array<int, string> $summaryLabels
* @param array{
* summary: string|null,
* 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
* @param array<int, array{checked: bool, indent: int, text: string}> $currentValue
* @param array<int, array{checked: bool, indent: int, text: string}> $newValue
*/
private function trackAcceptanceCriteriaChange(
array &$changes,