Files
KanbanGit/app/Actions/Tickets/UpdateProjectTicket.php
T
Koda 456b165987
linter / quality (pull_request) Successful in 1m22s
tests / ci (8.4) (pull_request) Successful in 1m32s
Rename acceptance criteria checklist helper to mapper
2026-05-19 23:35:14 +00:00

374 lines
12 KiB
PHP

<?php
namespace App\Actions\Tickets;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\TicketWorklogEntryType;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class UpdateProjectTicket
{
public function __construct(
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
) {}
/**
* @param array{
* 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,
* 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,
* story_points?: int|null,
* ticket_priority_id: int,
* ticket_type_id: int,
* parent_ticket_id: int|null,
* title: string
* } $validated
*/
public function handle(
Ticket $ticket,
User $actor,
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, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
if (! $ticketAttributes['is_blocked']) {
$ticketAttributes['blocked_comment'] = null;
}
$ticket->loadMissing([
'assignee',
'author',
'acceptanceCriteria',
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
'project',
'ticketPriority',
'ticketType',
]);
/** @var Collection<int, TicketFieldDefinition> $fieldDefinitions */
$fieldDefinitions = $ticket->project->ticketFieldDefinitions()
->get()
->keyBy('id');
$ticketTypeOptions = $ticket->project->ticketTypes()
->get()
->keyBy('id');
$ticketPriorityOptions = $ticket->project->ticketPriorities()
->get()
->keyBy('id');
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id');
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$changes = [];
$summaryLabels = [];
$this->trackScalarChange(
$changes,
$summaryLabels,
'title',
'title',
$ticket->title,
$ticketAttributes['title'],
);
$this->trackScalarChange(
$changes,
$summaryLabels,
'description',
'description',
$ticket->description,
$ticketAttributes['description'],
);
$this->trackAcceptanceCriteriaChange(
$changes,
$summaryLabels,
$currentAcceptanceCriteria,
$nextAcceptanceCriteria,
);
$this->trackScalarChange(
$changes,
$summaryLabels,
'blocked_comment',
'blocker comment',
$ticket->blocked_comment,
$ticketAttributes['blocked_comment'],
);
$this->trackNumberChange(
$changes,
$summaryLabels,
'story_points',
'story points',
$ticket->story_points,
$ticketAttributes['story_points'],
);
if ($ticket->ticket_type_id !== $ticketAttributes['ticket_type_id']) {
$changes['ticket_type'] = [
'from' => $ticket->ticketType?->name,
'to' => $ticketTypeOptions->get($ticketAttributes['ticket_type_id'])?->name,
];
$summaryLabels[] = 'type';
}
if ($ticket->ticket_priority_id !== $ticketAttributes['ticket_priority_id']) {
$changes['ticket_priority'] = [
'from' => $ticket->ticketPriority?->name,
'to' => $ticketPriorityOptions->get($ticketAttributes['ticket_priority_id'])?->name,
];
$summaryLabels[] = 'priority';
}
if ($ticket->is_blocked !== $ticketAttributes['is_blocked']) {
$changes['is_blocked'] = [
'from' => $ticket->is_blocked,
'to' => $ticketAttributes['is_blocked'],
];
$summaryLabels[] = 'blocked state';
}
if ($ticket->parent_ticket_id !== $ticketAttributes['parent_ticket_id']) {
$newParentTicket = $ticketAttributes['parent_ticket_id'] === null
? null
: Ticket::query()->find($ticketAttributes['parent_ticket_id']);
$changes['parent_ticket'] = [
'from' => $ticket->parentTicket?->ticket_key,
'to' => $newParentTicket?->ticket_key,
];
$summaryLabels[] = 'parent ticket';
}
if ($ticket->assignee_user_id !== $ticketAttributes['assignee_user_id']) {
$changes['assignee'] = [
'from' => $ticket->assignee?->name,
'to' => $ticketAttributes['assignee_user_id'] === null
? null
: User::query()->find($ticketAttributes['assignee_user_id'])?->name,
];
$summaryLabels[] = 'assignee';
}
if ($ticket->author_user_id !== $ticketAttributes['author_user_id']) {
$changes['author'] = [
'from' => $ticket->author?->name,
'to' => $ticketAttributes['author_user_id'] === null
? null
: User::query()->find($ticketAttributes['author_user_id'])?->name,
];
$summaryLabels[] = 'author';
}
$ticket->fill($ticketAttributes);
if ($ticket->isDirty()) {
$ticket->save();
}
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
}
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
$ticket,
$fieldDefinitions,
$existingFieldValues,
$fieldValueAttributes,
);
if ($fieldValueChangeSet['changes'] !== []) {
$changes['field_values'] = $fieldValueChangeSet['changes'];
$summaryLabels = [
...$summaryLabels,
...$fieldValueChangeSet['summary_labels'],
];
}
if ($summaryLabels === []) {
return false;
}
$this->createTicketWorklogEntry->handle(
$ticket->fresh(),
$actor,
TicketWorklogEntryType::Updated,
$this->buildSummary($summaryLabels),
$changes,
);
return true;
});
}
/**
* @param array{
* 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,
* description: string|null,
* is_blocked: bool,
* parent_ticket_id: int|null,
* story_points?: int|null,
* ticket_priority_id: int,
* ticket_type_id: int,
* title: string
* } $validated
* @return array{
* assignee_user_id: int|null,
* author_user_id: int|null,
* blocked_comment: string|null,
* description: string|null,
* is_blocked: bool,
* parent_ticket_id: int|null,
* story_points: int|null,
* ticket_priority_id: int,
* ticket_type_id: int,
* title: string
* }
*/
private function buildTicketAttributes(Ticket $ticket, array $validated): array
{
return [
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
? Arr::get($validated, 'assignee_user_id')
: $ticket->assignee_user_id,
'author_user_id' => Arr::exists($validated, 'author_user_id')
? Arr::get($validated, 'author_user_id')
: $ticket->author_user_id,
'blocked_comment' => Arr::get($validated, 'blocked_comment'),
'description' => Arr::get($validated, 'description'),
'is_blocked' => Arr::get($validated, 'is_blocked', false),
'parent_ticket_id' => Arr::get($validated, 'parent_ticket_id'),
'story_points' => Arr::exists($validated, 'story_points')
? Arr::get($validated, 'story_points')
: $ticket->story_points,
'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'),
'ticket_type_id' => Arr::get($validated, 'ticket_type_id'),
'title' => Arr::get($validated, 'title'),
];
}
/**
* @param array<string, mixed> $changes
* @param array<int, string> $summaryLabels
* @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,
array &$summaryLabels,
array $currentValue,
array $newValue,
): void {
if ($currentValue === $newValue) {
return;
}
$changes['acceptance_criteria'] = [
'from' => $currentValue,
'to' => $newValue,
];
$summaryLabels[] = 'acceptance criteria';
}
/**
* @param array<string, mixed> $changes
* @param array<int, string> $summaryLabels
*/
private function trackScalarChange(
array &$changes,
array &$summaryLabels,
string $changeKey,
string $summaryLabel,
?string $currentValue,
?string $newValue,
): void {
if ($currentValue === $newValue) {
return;
}
$changes[$changeKey] = [
'from' => $currentValue,
'to' => $newValue,
];
$summaryLabels[] = $summaryLabel;
}
/**
* @param array<string, mixed> $changes
* @param array<int, string> $summaryLabels
*/
private function trackNumberChange(
array &$changes,
array &$summaryLabels,
string $changeKey,
string $summaryLabel,
?int $currentValue,
?int $newValue,
): void {
if ($currentValue === $newValue) {
return;
}
$changes[$changeKey] = [
'from' => $currentValue,
'to' => $newValue,
];
$summaryLabels[] = $summaryLabel;
}
/**
* @param array<int, string> $summaryLabels
*/
private function buildSummary(array $summaryLabels): string
{
$uniqueLabels = collect($summaryLabels)
->unique()
->values();
if ($uniqueLabels->count() === 1) {
return 'Updated '.$uniqueLabels->first().'.';
}
if ($uniqueLabels->count() === 2) {
return 'Updated '.$uniqueLabels->first().' and '.$uniqueLabels->last().'.';
}
$lastLabel = $uniqueLabels->pop();
return 'Updated '.$uniqueLabels->implode(', ').', and '.$lastLabel.'.';
}
}