260 lines
8.2 KiB
PHP
260 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace Leantime\Domain\Tickets\Support;
|
|
|
|
use Illuminate\Support\Str;
|
|
use Leantime\Core\Support\AbstractEntityFormatter;
|
|
use Leantime\Domain\Projects\Models\Project;
|
|
use Leantime\Domain\Tickets\Models\Tickets;
|
|
|
|
/**
|
|
* Ticket entity formatter for AI consumption.
|
|
*
|
|
* Formats ticket/task entities into structured markdown suitable for AI prompts,
|
|
* embeddings, and other LLM operations.
|
|
*/
|
|
class TicketFormatter extends AbstractEntityFormatter
|
|
{
|
|
/**
|
|
* Fields to exclude from ticket formatting.
|
|
*/
|
|
protected array $excludedFields = [
|
|
'sortIndex',
|
|
'timelineDate',
|
|
'timelineDateToFinish',
|
|
'timeToFinish',
|
|
'timeFrom',
|
|
'timeTo',
|
|
'url',
|
|
'doneTickets',
|
|
'allTickets',
|
|
'children',
|
|
'editorProfileId',
|
|
];
|
|
|
|
/**
|
|
* Priority order for displaying ticket fields.
|
|
*/
|
|
protected array $fieldPriority = [
|
|
'id',
|
|
'headline',
|
|
'description',
|
|
'type',
|
|
'status',
|
|
'priority',
|
|
'projectName',
|
|
'assignedTo',
|
|
'dueDate',
|
|
'storypoints',
|
|
'planHours',
|
|
];
|
|
|
|
public function __construct(
|
|
protected Tickets $ticket,
|
|
protected Project|array|null $project = null,
|
|
protected array|false|null $subtasks = null
|
|
) {}
|
|
|
|
/**
|
|
* Get the entity type.
|
|
*/
|
|
public function getEntityType(): string
|
|
{
|
|
return 'ticket';
|
|
}
|
|
|
|
/**
|
|
* Get the entity ID.
|
|
*/
|
|
public function getEntityId(): mixed
|
|
{
|
|
return $this->ticket->id;
|
|
}
|
|
|
|
/**
|
|
* Prepare ticket data for formatting.
|
|
*/
|
|
protected function prepareEntityData(array $context = []): array
|
|
{
|
|
$data = [
|
|
'id' => $this->ticket->id,
|
|
'headline' => $this->sanitizeValue($this->ticket->headline),
|
|
'type' => $this->sanitizeValue($this->ticket->type),
|
|
'description' => $this->sanitizeValue($this->ticket->description),
|
|
'status' => $this->ticket->status,
|
|
'priority' => $this->ticket->priority,
|
|
'storypoints' => $this->sanitizeValue($this->ticket->storypoints),
|
|
'dueDate' => $this->formatDate($this->ticket->dateToFinish),
|
|
'planHours' => $this->sanitizeValue($this->ticket->planHours),
|
|
'scheduledFrom' => $this->formatDate($this->ticket->editFrom),
|
|
'scheduledTo' => $this->formatDate($this->ticket->editTo),
|
|
'assignedTo' => $this->formatAssignedUser(),
|
|
'createdBy' => $this->formatCreatedUser(),
|
|
'projectId' => $this->ticket->projectId,
|
|
'projectName' => $this->sanitizeValue($this->ticket->projectName),
|
|
'clientName' => $this->sanitizeValue($this->ticket->clientName),
|
|
'milestone' => $this->formatMilestone(),
|
|
'acceptanceCriteria' => $this->sanitizeValue($this->ticket->acceptanceCriteria),
|
|
'tags' => $this->sanitizeValue($this->ticket->tags),
|
|
'dependingTicketId' => $this->ticket->dependingTicketId,
|
|
'parentHeadline' => $this->sanitizeValue($this->ticket->parentHeadline),
|
|
// "Last updated" must reflect the last modification, not creation.
|
|
// It was reading ->date (the create timestamp), so every ticket
|
|
// reported its creation date as "last updated". Fall back to date
|
|
// only when modified is absent (legacy rows / partial selects).
|
|
'lastUpdated' => $this->formatDate($this->ticket->modified ?: $this->ticket->date),
|
|
'bookedHours' => $this->sanitizeValue($this->ticket->bookedHours),
|
|
'hourRemaining' => $this->sanitizeValue($this->ticket->hourRemaining),
|
|
'percentDone' => $this->sanitizeValue($this->ticket->percentDone),
|
|
];
|
|
|
|
// Add project information if available
|
|
if ($this->project) {
|
|
if (is_array($this->project)) {
|
|
$this->project = new Project($this->project);
|
|
}
|
|
|
|
$data['projectDetails'] = [
|
|
'name' => $this->sanitizeValue($this->project->name),
|
|
'type' => $this->sanitizeValue($this->project->type),
|
|
'state' => $this->sanitizeValue($this->project->state),
|
|
'progress' => $this->sanitizeValue($this->project->progress),
|
|
'status' => $this->sanitizeValue($this->project->status),
|
|
];
|
|
}
|
|
|
|
if ($this->subtasks) {
|
|
$data['subtasks'] = $this->formatSubtasks($this->subtasks);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Format the header section.
|
|
*/
|
|
protected function formatHeader(array $data): string
|
|
{
|
|
$type = ! empty($data['type']) ? strtoupper($data['type']) : 'TASK';
|
|
$projectInfo = ! empty($data['projectName']) ? " ({$data['projectName']})" : '';
|
|
|
|
return "## {$type} #{$data['id']} - {$data['headline']}{$projectInfo}";
|
|
}
|
|
|
|
/**
|
|
* Format the body with custom ticket-specific formatting.
|
|
*/
|
|
protected function formatBody(array $data, array $context = []): string
|
|
{
|
|
$filteredData = $this->filterFields($data, $context);
|
|
|
|
// Custom formatting for specific fields
|
|
if (isset($filteredData['priority'])) {
|
|
$filteredData['priority'] = $this->formatPriority($filteredData['priority']);
|
|
}
|
|
|
|
// Remove the duplicated header fields from body
|
|
unset($filteredData['id'], $filteredData['headline'], $filteredData['projectName']);
|
|
|
|
$sortedData = $this->sortFields($filteredData);
|
|
|
|
return "\n".Str::toMarkdown($sortedData);
|
|
}
|
|
|
|
/**
|
|
* Format a compact summary.
|
|
*/
|
|
protected function formatSummary(array $data): string
|
|
{
|
|
$type = ! empty($data['type']) ? strtoupper($data['type']) : 'TASK';
|
|
$status = $this->formatSimpleStatus($data['status'] ?? '');
|
|
$assignee = ! empty($data['assignedTo']) ? " → {$data['assignedTo']}" : '';
|
|
|
|
return "{$type} #{$data['id']}: {$data['headline']}{$status}{$assignee}";
|
|
}
|
|
|
|
/**
|
|
* Format subtasks into a list string.
|
|
*/
|
|
protected function formatSubtasks(array $subtasks): string
|
|
{
|
|
$subtaskData = '';
|
|
foreach ($subtasks as $subtask) {
|
|
$subtaskData .= ' #'.$subtask['id'].' '.$this->sanitizeValue($subtask['headline'])."\n";
|
|
}
|
|
|
|
return $subtaskData;
|
|
}
|
|
|
|
/**
|
|
* Format the assigned user information.
|
|
*/
|
|
protected function formatAssignedUser(): string
|
|
{
|
|
$firstName = $this->sanitizeValue($this->ticket->editorFirstname);
|
|
$lastName = $this->sanitizeValue($this->ticket->editorLastname);
|
|
|
|
if (empty($firstName) && empty($lastName)) {
|
|
return 'Unassigned';
|
|
}
|
|
|
|
$name = trim($firstName.' '.$lastName);
|
|
$id = $this->ticket->editorId;
|
|
|
|
return ! empty($id) ? "{$name} (ID: {$id})" : $name;
|
|
}
|
|
|
|
/**
|
|
* Format the user who created the ticket.
|
|
*/
|
|
protected function formatCreatedUser(): string
|
|
{
|
|
$firstName = $this->sanitizeValue($this->ticket->userFirstname);
|
|
$lastName = $this->sanitizeValue($this->ticket->userLastname);
|
|
|
|
if (empty($firstName) && empty($lastName)) {
|
|
return 'Unknown';
|
|
}
|
|
|
|
$name = trim($firstName.' '.$lastName);
|
|
$id = $this->ticket->userId;
|
|
|
|
return ! empty($id) ? "{$name} (ID: {$id})" : $name;
|
|
}
|
|
|
|
/**
|
|
* Format milestone information.
|
|
*/
|
|
protected function formatMilestone(): string
|
|
{
|
|
if (empty($this->ticket->milestoneHeadline) && empty($this->ticket->milestoneid)) {
|
|
return 'No milestone';
|
|
}
|
|
|
|
$headline = $this->sanitizeValue($this->ticket->milestoneHeadline) ?: 'Untitled Milestone';
|
|
$id = $this->ticket->milestoneid;
|
|
|
|
return ! empty($id) ? "{$headline} (ID: {$id})" : $headline;
|
|
}
|
|
|
|
/**
|
|
* Format status for summary (simple version).
|
|
*
|
|
* @param mixed $status
|
|
*/
|
|
protected function formatSimpleStatus($status): string
|
|
{
|
|
if (empty($status)) {
|
|
return '';
|
|
}
|
|
|
|
// Simple status indicators for summaries
|
|
return match ((string) $status) {
|
|
'1' => ' [NEW]',
|
|
'2' => ' [IN PROGRESS]',
|
|
'3' => ' [DONE]',
|
|
default => " [{$status}]"
|
|
};
|
|
}
|
|
}
|