Add checklist acceptance criteria
This commit is contained in:
@@ -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<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* },
|
||||
* 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'),
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -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<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* },
|
||||
* assignee_user_id?: int|null,
|
||||
* author_user_id?: int|null,
|
||||
* blocked_comment: string|null,
|
||||
@@ -77,6 +85,14 @@ 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'],
|
||||
);
|
||||
|
||||
$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<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* },
|
||||
* 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<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* }|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<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
|
||||
*/
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user