diff --git a/src/php/Controllers/Api/IssueController.php b/src/php/Controllers/Api/IssueController.php new file mode 100644 index 0000000..a11516d --- /dev/null +++ b/src/php/Controllers/Api/IssueController.php @@ -0,0 +1,47 @@ +json(['data' => [], 'total' => 0]); + } + + public function show(Request $request, string $slug): JsonResponse + { + return response()->json(['data' => null], 404); + } + + public function store(Request $request): JsonResponse + { + return response()->json(['data' => null], 501); + } + + public function update(Request $request, string $slug): JsonResponse + { + return response()->json(['data' => null], 501); + } + + public function destroy(Request $request, string $slug): JsonResponse + { + return response()->json(['data' => null], 501); + } + + public function comments(Request $request, string $slug): JsonResponse + { + return response()->json(['data' => [], 'total' => 0]); + } + + public function addComment(Request $request, string $slug): JsonResponse + { + return response()->json(['data' => null], 501); + } +} diff --git a/src/php/Controllers/Api/SprintController.php b/src/php/Controllers/Api/SprintController.php new file mode 100644 index 0000000..052d694 --- /dev/null +++ b/src/php/Controllers/Api/SprintController.php @@ -0,0 +1,37 @@ +json(['data' => [], 'total' => 0]); + } + + public function show(Request $request, string $slug): JsonResponse + { + return response()->json(['data' => null], 404); + } + + public function store(Request $request): JsonResponse + { + return response()->json(['data' => null], 501); + } + + public function update(Request $request, string $slug): JsonResponse + { + return response()->json(['data' => null], 501); + } + + public function destroy(Request $request, string $slug): JsonResponse + { + return response()->json(['data' => null], 501); + } +} diff --git a/src/php/Migrations/0001_01_01_000011_create_issue_tracker_tables.php b/src/php/Migrations/0001_01_01_000011_create_issue_tracker_tables.php new file mode 100644 index 0000000..addbb02 --- /dev/null +++ b/src/php/Migrations/0001_01_01_000011_create_issue_tracker_tables.php @@ -0,0 +1,94 @@ +id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->string('slug')->unique(); + $table->string('title'); + $table->text('description')->nullable(); + $table->text('goal')->nullable(); + $table->string('status', 32)->default('planning'); + $table->json('metadata')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['workspace_id', 'status']); + }); + } + + if (! Schema::hasTable('issues')) { + Schema::create('issues', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('sprint_id')->nullable()->constrained('sprints')->nullOnDelete(); + $table->string('slug')->unique(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('type', 32)->default('task'); + $table->string('status', 32)->default('open'); + $table->string('priority', 32)->default('normal'); + $table->json('labels')->nullable(); + $table->string('assignee')->nullable(); + $table->string('reporter')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('closed_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['workspace_id', 'status']); + $table->index(['workspace_id', 'sprint_id']); + $table->index(['workspace_id', 'priority']); + $table->index(['workspace_id', 'type']); + }); + } + + if (! Schema::hasTable('issue_comments')) { + Schema::create('issue_comments', function (Blueprint $table) { + $table->id(); + $table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete(); + $table->string('author'); + $table->text('body'); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index('issue_id'); + }); + } + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + + Schema::dropIfExists('issue_comments'); + Schema::dropIfExists('issues'); + Schema::dropIfExists('sprints'); + + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/src/php/Models/Issue.php b/src/php/Models/Issue.php new file mode 100644 index 0000000..ddfb02d --- /dev/null +++ b/src/php/Models/Issue.php @@ -0,0 +1,271 @@ + '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'; + + // 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(); + } +} diff --git a/src/php/Models/IssueComment.php b/src/php/Models/IssueComment.php new file mode 100644 index 0000000..ed498c2 --- /dev/null +++ b/src/php/Models/IssueComment.php @@ -0,0 +1,51 @@ + 'array', + ]; + + public function issue(): BelongsTo + { + return $this->belongsTo(Issue::class); + } + + public function toMcpContext(): array + { + return [ + 'id' => $this->id, + 'author' => $this->author, + 'body' => $this->body, + 'metadata' => $this->metadata, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/src/php/Models/Sprint.php b/src/php/Models/Sprint.php new file mode 100644 index 0000000..f8a5213 --- /dev/null +++ b/src/php/Models/Sprint.php @@ -0,0 +1,191 @@ + 'array', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + 'archived_at' => 'datetime', + ]; + + public const STATUS_PLANNING = 'planning'; + + public const STATUS_ACTIVE = 'active'; + + public const STATUS_COMPLETED = 'completed'; + + public const STATUS_CANCELLED = 'cancelled'; + + // Relationships + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function issues(): HasMany + { + return $this->hasMany(Issue::class); + } + + // Scopes + + public function scopeActive(Builder $query): Builder + { + return $query->where('status', self::STATUS_ACTIVE); + } + + public function scopePlanning(Builder $query): Builder + { + return $query->where('status', self::STATUS_PLANNING); + } + + public function scopeNotCancelled(Builder $query): Builder + { + return $query->where('status', '!=', self::STATUS_CANCELLED); + } + + // 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 activate(): self + { + $this->update([ + 'status' => self::STATUS_ACTIVE, + 'started_at' => $this->started_at ?? now(), + ]); + + return $this; + } + + public function complete(): self + { + $this->update([ + 'status' => self::STATUS_COMPLETED, + 'ended_at' => now(), + ]); + + return $this; + } + + public function cancel(?string $reason = null): self + { + $metadata = $this->metadata ?? []; + if ($reason) { + $metadata['cancel_reason'] = $reason; + } + + $this->update([ + 'status' => self::STATUS_CANCELLED, + 'ended_at' => now(), + 'archived_at' => now(), + 'metadata' => $metadata, + ]); + + return $this; + } + + public function getProgress(): array + { + $issues = $this->issues; + $total = $issues->count(); + $closed = $issues->where('status', Issue::STATUS_CLOSED)->count(); + $inProgress = $issues->where('status', Issue::STATUS_IN_PROGRESS)->count(); + + return [ + 'total' => $total, + 'closed' => $closed, + 'in_progress' => $inProgress, + 'open' => $total - $closed - $inProgress, + 'percentage' => $total > 0 ? round(($closed / $total) * 100) : 0, + ]; + } + + public function toMcpContext(): array + { + return [ + 'slug' => $this->slug, + 'title' => $this->title, + 'description' => $this->description, + 'goal' => $this->goal, + 'status' => $this->status, + 'progress' => $this->getProgress(), + 'started_at' => $this->started_at?->toIso8601String(), + 'ended_at' => $this->ended_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['title', 'status']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } +} diff --git a/src/php/Routes/api.php b/src/php/Routes/api.php index 9be59da..b41fbe2 100644 --- a/src/php/Routes/api.php +++ b/src/php/Routes/api.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Core\Mod\Agentic\Controllers\AgentApiController; +use Core\Mod\Agentic\Controllers\Api\IssueController; +use Core\Mod\Agentic\Controllers\Api\SprintController; use Core\Mod\Agentic\Middleware\AgentApiAuth; use Illuminate\Support\Facades\Route; @@ -58,3 +60,29 @@ Route::middleware(AgentApiAuth::class.':sessions.write')->group(function () { Route::post('v1/sessions/{sessionId}/end', [AgentApiController::class, 'endSession']); Route::post('v1/sessions/{sessionId}/continue', [AgentApiController::class, 'continueSession']); }); + +// Issue tracker +Route::middleware(AgentApiAuth::class.':issues.read')->group(function () { + Route::get('v1/issues', [IssueController::class, 'index']); + Route::get('v1/issues/{slug}', [IssueController::class, 'show']); + Route::get('v1/issues/{slug}/comments', [IssueController::class, 'comments']); +}); + +Route::middleware(AgentApiAuth::class.':issues.write')->group(function () { + Route::post('v1/issues', [IssueController::class, 'store']); + Route::patch('v1/issues/{slug}', [IssueController::class, 'update']); + Route::delete('v1/issues/{slug}', [IssueController::class, 'destroy']); + Route::post('v1/issues/{slug}/comments', [IssueController::class, 'addComment']); +}); + +// Sprints +Route::middleware(AgentApiAuth::class.':sprints.read')->group(function () { + Route::get('v1/sprints', [SprintController::class, 'index']); + Route::get('v1/sprints/{slug}', [SprintController::class, 'show']); +}); + +Route::middleware(AgentApiAuth::class.':sprints.write')->group(function () { + Route::post('v1/sprints', [SprintController::class, 'store']); + Route::patch('v1/sprints/{slug}', [SprintController::class, 'update']); + Route::delete('v1/sprints/{slug}', [SprintController::class, 'destroy']); +});