Add checklist acceptance criteria
linter / quality (pull_request) Successful in 1m16s
tests / ci (8.4) (pull_request) Successful in 1m33s

This commit is contained in:
2026-05-18 23:24:52 +00:00
parent f4661acf1b
commit 3ddfcc89f9
16 changed files with 937 additions and 96 deletions
@@ -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));
}
}