'array', 'metadata' => 'array', 'closed_at' => 'datetime', 'archived_at' => 'datetime', ]; // Status constants public const STATUS_OPEN = 'open'; public const STATUS_IN_PROGRESS = 'in_progress'; public const STATUS_REVIEW = 'review'; public const STATUS_CLOSED = 'closed'; // Type constants public const TYPE_BUG = 'bug'; public const TYPE_FEATURE = 'feature'; public const TYPE_TASK = 'task'; public const TYPE_IMPROVEMENT = 'improvement'; public const TYPE_EPIC = 'epic'; // Priority constants public const PRIORITY_LOW = 'low'; public const PRIORITY_NORMAL = 'normal'; public const PRIORITY_HIGH = 'high'; public const PRIORITY_URGENT = 'urgent'; // Relationships public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); } public function sprint(): BelongsTo { return $this->belongsTo(Sprint::class); } public function comments(): HasMany { return $this->hasMany(IssueComment::class)->orderBy('created_at'); } // Scopes public function scopeOpen(Builder $query): Builder { return $query->where('status', self::STATUS_OPEN); } public function scopeInProgress(Builder $query): Builder { return $query->where('status', self::STATUS_IN_PROGRESS); } public function scopeClosed(Builder $query): Builder { return $query->where('status', self::STATUS_CLOSED); } public function scopeNotClosed(Builder $query): Builder { return $query->where('status', '!=', self::STATUS_CLOSED); } public function scopeOfType(Builder $query, string $type): Builder { return $query->where('type', $type); } public function scopeOfPriority(Builder $query, string $priority): Builder { return $query->where('priority', $priority); } public function scopeForSprint(Builder $query, int $sprintId): Builder { return $query->where('sprint_id', $sprintId); } public function scopeWithLabel(Builder $query, string $label): Builder { return $query->whereJsonContains('labels', $label); } /** * Order by priority using CASE statement with whitelisted values. */ public function scopeOrderByPriority(Builder $query, string $direction = 'asc'): Builder { return $query->orderByRaw('CASE priority WHEN ? THEN 1 WHEN ? THEN 2 WHEN ? THEN 3 WHEN ? THEN 4 ELSE 5 END '.($direction === 'desc' ? 'DESC' : 'ASC'), [self::PRIORITY_URGENT, self::PRIORITY_HIGH, self::PRIORITY_NORMAL, self::PRIORITY_LOW]); } // Helpers public static function generateSlug(string $title): string { $baseSlug = Str::slug($title); $slug = $baseSlug; $count = 1; while (static::where('slug', $slug)->exists()) { $slug = "{$baseSlug}-{$count}"; $count++; } return $slug; } public function close(): self { $this->update([ 'status' => self::STATUS_CLOSED, 'closed_at' => now(), ]); return $this; } public function reopen(): self { $this->update([ 'status' => self::STATUS_OPEN, 'closed_at' => null, ]); return $this; } public function archive(?string $reason = null): self { $metadata = $this->metadata ?? []; if ($reason) { $metadata['archive_reason'] = $reason; } $this->update([ 'status' => self::STATUS_CLOSED, 'closed_at' => $this->closed_at ?? now(), 'archived_at' => now(), 'metadata' => $metadata, ]); return $this; } public function addLabel(string $label): self { $labels = $this->labels ?? []; if (! in_array($label, $labels, true)) { $labels[] = $label; $this->update(['labels' => $labels]); } return $this; } public function removeLabel(string $label): self { $labels = $this->labels ?? []; $labels = array_values(array_filter($labels, fn (string $l) => $l !== $label)); $this->update(['labels' => $labels]); return $this; } public function toMcpContext(): array { return [ 'slug' => $this->slug, 'title' => $this->title, 'description' => $this->description, 'type' => $this->type, 'status' => $this->status, 'priority' => $this->priority, 'labels' => $this->labels ?? [], 'assignee' => $this->assignee, 'reporter' => $this->reporter, 'sprint' => $this->sprint?->slug, 'comments_count' => $this->comments()->count(), 'metadata' => $this->metadata, 'closed_at' => $this->closed_at?->toIso8601String(), 'created_at' => $this->created_at?->toIso8601String(), 'updated_at' => $this->updated_at?->toIso8601String(), ]; } public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() ->logOnly(['title', 'status', 'priority', 'assignee', 'sprint_id']) ->logOnlyDirty() ->dontSubmitEmptyLogs(); } }