From 5a0b126f51b83eb77ab6f4b77b17f945874efb89 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 16 Mar 2026 05:53:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(issues):=20phase=203=20=E2=80=94=20Actions?= =?UTF-8?q?=20and=20API=20controllers=20for=20issues=20and=20sprints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue Actions: CreateIssue, GetIssue, ListIssues, UpdateIssue, ArchiveIssue, AddIssueComment with full validation and workspace scoping. Sprint Actions: CreateSprint, GetSprint, ListSprints, UpdateSprint, ArchiveSprint with status lifecycle management. IssueController: REST endpoints with filtering by status, type, priority, sprint, and label. Comment sub-resource endpoints. SprintController: REST endpoints with progress tracking. Co-Authored-By: Virgil --- src/php/Actions/Issue/AddIssueComment.php | 53 +++++ src/php/Actions/Issue/ArchiveIssue.php | 41 ++++ src/php/Actions/Issue/CreateIssue.php | 79 +++++++ src/php/Actions/Issue/GetIssue.php | 40 ++++ src/php/Actions/Issue/ListIssues.php | 91 ++++++++ src/php/Actions/Issue/UpdateIssue.php | 76 +++++++ src/php/Actions/Sprint/ArchiveSprint.php | 41 ++++ src/php/Actions/Sprint/CreateSprint.php | 56 +++++ src/php/Actions/Sprint/GetSprint.php | 40 ++++ src/php/Actions/Sprint/ListSprints.php | 48 ++++ src/php/Actions/Sprint/UpdateSprint.php | 60 +++++ src/php/Controllers/Api/IssueController.php | 219 ++++++++++++++++++- src/php/Controllers/Api/SprintController.php | 144 +++++++++++- 13 files changed, 976 insertions(+), 12 deletions(-) create mode 100644 src/php/Actions/Issue/AddIssueComment.php create mode 100644 src/php/Actions/Issue/ArchiveIssue.php create mode 100644 src/php/Actions/Issue/CreateIssue.php create mode 100644 src/php/Actions/Issue/GetIssue.php create mode 100644 src/php/Actions/Issue/ListIssues.php create mode 100644 src/php/Actions/Issue/UpdateIssue.php create mode 100644 src/php/Actions/Sprint/ArchiveSprint.php create mode 100644 src/php/Actions/Sprint/CreateSprint.php create mode 100644 src/php/Actions/Sprint/GetSprint.php create mode 100644 src/php/Actions/Sprint/ListSprints.php create mode 100644 src/php/Actions/Sprint/UpdateSprint.php diff --git a/src/php/Actions/Issue/AddIssueComment.php b/src/php/Actions/Issue/AddIssueComment.php new file mode 100644 index 0000000..e9b2228 --- /dev/null +++ b/src/php/Actions/Issue/AddIssueComment.php @@ -0,0 +1,53 @@ +where('slug', $slug) + ->first(); + + if (! $issue) { + throw new \InvalidArgumentException("Issue not found: {$slug}"); + } + + return IssueComment::create([ + 'issue_id' => $issue->id, + 'author' => $author, + 'body' => $body, + 'metadata' => $metadata, + ]); + } +} diff --git a/src/php/Actions/Issue/ArchiveIssue.php b/src/php/Actions/Issue/ArchiveIssue.php new file mode 100644 index 0000000..b491400 --- /dev/null +++ b/src/php/Actions/Issue/ArchiveIssue.php @@ -0,0 +1,41 @@ +where('slug', $slug) + ->first(); + + if (! $issue) { + throw new \InvalidArgumentException("Issue not found: {$slug}"); + } + + $issue->archive($reason); + + return $issue->fresh(); + } +} diff --git a/src/php/Actions/Issue/CreateIssue.php b/src/php/Actions/Issue/CreateIssue.php new file mode 100644 index 0000000..fd0812f --- /dev/null +++ b/src/php/Actions/Issue/CreateIssue.php @@ -0,0 +1,79 @@ + 'Fix login bug', 'type' => 'bug'], 1); + */ +class CreateIssue +{ + use Action; + + /** + * @param array{title: string, slug?: string, description?: string, type?: string, priority?: string, labels?: array, assignee?: string, reporter?: string, sprint_id?: int, metadata?: array} $data + * + * @throws \InvalidArgumentException + */ + public function handle(array $data, int $workspaceId): Issue + { + $title = $data['title'] ?? null; + if (! is_string($title) || $title === '' || mb_strlen($title) > 255) { + throw new \InvalidArgumentException('title is required and must be a non-empty string (max 255 characters)'); + } + + $slug = $data['slug'] ?? null; + if ($slug !== null) { + if (! is_string($slug) || mb_strlen($slug) > 255) { + throw new \InvalidArgumentException('slug must be a string (max 255 characters)'); + } + } else { + $slug = Str::slug($title).'-'.Str::random(6); + } + + if (Issue::where('slug', $slug)->exists()) { + throw new \InvalidArgumentException("Issue with slug '{$slug}' already exists"); + } + + $type = $data['type'] ?? Issue::TYPE_TASK; + $validTypes = [Issue::TYPE_BUG, Issue::TYPE_FEATURE, Issue::TYPE_TASK, Issue::TYPE_IMPROVEMENT]; + if (! in_array($type, $validTypes, true)) { + throw new \InvalidArgumentException( + sprintf('type must be one of: %s', implode(', ', $validTypes)) + ); + } + + $priority = $data['priority'] ?? Issue::PRIORITY_NORMAL; + $validPriorities = [Issue::PRIORITY_LOW, Issue::PRIORITY_NORMAL, Issue::PRIORITY_HIGH, Issue::PRIORITY_URGENT]; + if (! in_array($priority, $validPriorities, true)) { + throw new \InvalidArgumentException( + sprintf('priority must be one of: %s', implode(', ', $validPriorities)) + ); + } + + $issue = Issue::create([ + 'workspace_id' => $workspaceId, + 'sprint_id' => $data['sprint_id'] ?? null, + 'slug' => $slug, + 'title' => $title, + 'description' => $data['description'] ?? null, + 'type' => $type, + 'status' => Issue::STATUS_OPEN, + 'priority' => $priority, + 'labels' => $data['labels'] ?? [], + 'assignee' => $data['assignee'] ?? null, + 'reporter' => $data['reporter'] ?? null, + 'metadata' => $data['metadata'] ?? [], + ]); + + return $issue->load('sprint'); + } +} diff --git a/src/php/Actions/Issue/GetIssue.php b/src/php/Actions/Issue/GetIssue.php new file mode 100644 index 0000000..59cec75 --- /dev/null +++ b/src/php/Actions/Issue/GetIssue.php @@ -0,0 +1,40 @@ +forWorkspace($workspaceId) + ->where('slug', $slug) + ->first(); + + if (! $issue) { + throw new \InvalidArgumentException("Issue not found: {$slug}"); + } + + return $issue; + } +} diff --git a/src/php/Actions/Issue/ListIssues.php b/src/php/Actions/Issue/ListIssues.php new file mode 100644 index 0000000..9513229 --- /dev/null +++ b/src/php/Actions/Issue/ListIssues.php @@ -0,0 +1,91 @@ + + */ + public function handle( + int $workspaceId, + ?string $status = null, + ?string $type = null, + ?string $priority = null, + ?string $sprintSlug = null, + ?string $label = null, + bool $includeClosed = false, + ): Collection { + $validStatuses = [Issue::STATUS_OPEN, Issue::STATUS_IN_PROGRESS, Issue::STATUS_REVIEW, Issue::STATUS_CLOSED]; + if ($status !== null && ! in_array($status, $validStatuses, true)) { + throw new \InvalidArgumentException( + sprintf('status must be one of: %s', implode(', ', $validStatuses)) + ); + } + + $validTypes = [Issue::TYPE_BUG, Issue::TYPE_FEATURE, Issue::TYPE_TASK, Issue::TYPE_IMPROVEMENT]; + if ($type !== null && ! in_array($type, $validTypes, true)) { + throw new \InvalidArgumentException( + sprintf('type must be one of: %s', implode(', ', $validTypes)) + ); + } + + $validPriorities = [Issue::PRIORITY_LOW, Issue::PRIORITY_NORMAL, Issue::PRIORITY_HIGH, Issue::PRIORITY_URGENT]; + if ($priority !== null && ! in_array($priority, $validPriorities, true)) { + throw new \InvalidArgumentException( + sprintf('priority must be one of: %s', implode(', ', $validPriorities)) + ); + } + + $query = Issue::with('sprint') + ->forWorkspace($workspaceId) + ->orderByPriority() + ->orderBy('updated_at', 'desc'); + + if (! $includeClosed && $status !== Issue::STATUS_CLOSED) { + $query->notClosed(); + } + + if ($status !== null) { + $query->where('status', $status); + } + + if ($type !== null) { + $query->ofType($type); + } + + if ($priority !== null) { + $query->ofPriority($priority); + } + + if ($sprintSlug !== null) { + $sprint = Sprint::forWorkspace($workspaceId)->where('slug', $sprintSlug)->first(); + if (! $sprint) { + throw new \InvalidArgumentException("Sprint not found: {$sprintSlug}"); + } + $query->forSprint($sprint->id); + } + + if ($label !== null) { + $query->withLabel($label); + } + + return $query->get(); + } +} diff --git a/src/php/Actions/Issue/UpdateIssue.php b/src/php/Actions/Issue/UpdateIssue.php new file mode 100644 index 0000000..68ef48c --- /dev/null +++ b/src/php/Actions/Issue/UpdateIssue.php @@ -0,0 +1,76 @@ + 'in_progress'], 1); + */ +class UpdateIssue +{ + use Action; + + /** + * @param array{status?: string, priority?: string, type?: string, title?: string, description?: string, assignee?: string, sprint_id?: int|null, labels?: array} $data + * + * @throws \InvalidArgumentException + */ + public function handle(string $slug, array $data, int $workspaceId): Issue + { + if ($slug === '') { + throw new \InvalidArgumentException('slug is required'); + } + + $issue = Issue::forWorkspace($workspaceId) + ->where('slug', $slug) + ->first(); + + if (! $issue) { + throw new \InvalidArgumentException("Issue not found: {$slug}"); + } + + if (isset($data['status'])) { + $valid = [Issue::STATUS_OPEN, Issue::STATUS_IN_PROGRESS, Issue::STATUS_REVIEW, Issue::STATUS_CLOSED]; + if (! in_array($data['status'], $valid, true)) { + throw new \InvalidArgumentException( + sprintf('status must be one of: %s', implode(', ', $valid)) + ); + } + + if ($data['status'] === Issue::STATUS_CLOSED) { + $data['closed_at'] = now(); + } elseif ($issue->status === Issue::STATUS_CLOSED) { + $data['closed_at'] = null; + } + } + + if (isset($data['priority'])) { + $valid = [Issue::PRIORITY_LOW, Issue::PRIORITY_NORMAL, Issue::PRIORITY_HIGH, Issue::PRIORITY_URGENT]; + if (! in_array($data['priority'], $valid, true)) { + throw new \InvalidArgumentException( + sprintf('priority must be one of: %s', implode(', ', $valid)) + ); + } + } + + if (isset($data['type'])) { + $valid = [Issue::TYPE_BUG, Issue::TYPE_FEATURE, Issue::TYPE_TASK, Issue::TYPE_IMPROVEMENT]; + if (! in_array($data['type'], $valid, true)) { + throw new \InvalidArgumentException( + sprintf('type must be one of: %s', implode(', ', $valid)) + ); + } + } + + $issue->update($data); + + return $issue->fresh()->load('sprint'); + } +} diff --git a/src/php/Actions/Sprint/ArchiveSprint.php b/src/php/Actions/Sprint/ArchiveSprint.php new file mode 100644 index 0000000..58edc2c --- /dev/null +++ b/src/php/Actions/Sprint/ArchiveSprint.php @@ -0,0 +1,41 @@ +where('slug', $slug) + ->first(); + + if (! $sprint) { + throw new \InvalidArgumentException("Sprint not found: {$slug}"); + } + + $sprint->cancel($reason); + + return $sprint->fresh(); + } +} diff --git a/src/php/Actions/Sprint/CreateSprint.php b/src/php/Actions/Sprint/CreateSprint.php new file mode 100644 index 0000000..88efad0 --- /dev/null +++ b/src/php/Actions/Sprint/CreateSprint.php @@ -0,0 +1,56 @@ + 'Sprint 1', 'goal' => 'MVP launch'], 1); + */ +class CreateSprint +{ + use Action; + + /** + * @param array{title: string, slug?: string, description?: string, goal?: string, metadata?: array} $data + * + * @throws \InvalidArgumentException + */ + public function handle(array $data, int $workspaceId): Sprint + { + $title = $data['title'] ?? null; + if (! is_string($title) || $title === '' || mb_strlen($title) > 255) { + throw new \InvalidArgumentException('title is required and must be a non-empty string (max 255 characters)'); + } + + $slug = $data['slug'] ?? null; + if ($slug !== null) { + if (! is_string($slug) || mb_strlen($slug) > 255) { + throw new \InvalidArgumentException('slug must be a string (max 255 characters)'); + } + } else { + $slug = Str::slug($title).'-'.Str::random(6); + } + + if (Sprint::where('slug', $slug)->exists()) { + throw new \InvalidArgumentException("Sprint with slug '{$slug}' already exists"); + } + + return Sprint::create([ + 'workspace_id' => $workspaceId, + 'slug' => $slug, + 'title' => $title, + 'description' => $data['description'] ?? null, + 'goal' => $data['goal'] ?? null, + 'status' => Sprint::STATUS_PLANNING, + 'metadata' => $data['metadata'] ?? [], + ]); + } +} diff --git a/src/php/Actions/Sprint/GetSprint.php b/src/php/Actions/Sprint/GetSprint.php new file mode 100644 index 0000000..0f44a5a --- /dev/null +++ b/src/php/Actions/Sprint/GetSprint.php @@ -0,0 +1,40 @@ +forWorkspace($workspaceId) + ->where('slug', $slug) + ->first(); + + if (! $sprint) { + throw new \InvalidArgumentException("Sprint not found: {$slug}"); + } + + return $sprint; + } +} diff --git a/src/php/Actions/Sprint/ListSprints.php b/src/php/Actions/Sprint/ListSprints.php new file mode 100644 index 0000000..80db23f --- /dev/null +++ b/src/php/Actions/Sprint/ListSprints.php @@ -0,0 +1,48 @@ + + */ + public function handle(int $workspaceId, ?string $status = null, bool $includeCancelled = false): Collection + { + $validStatuses = [Sprint::STATUS_PLANNING, Sprint::STATUS_ACTIVE, Sprint::STATUS_COMPLETED, Sprint::STATUS_CANCELLED]; + if ($status !== null && ! in_array($status, $validStatuses, true)) { + throw new \InvalidArgumentException( + sprintf('status must be one of: %s', implode(', ', $validStatuses)) + ); + } + + $query = Sprint::with('issues') + ->forWorkspace($workspaceId) + ->orderBy('updated_at', 'desc'); + + if (! $includeCancelled && $status !== Sprint::STATUS_CANCELLED) { + $query->notCancelled(); + } + + if ($status !== null) { + $query->where('status', $status); + } + + return $query->get(); + } +} diff --git a/src/php/Actions/Sprint/UpdateSprint.php b/src/php/Actions/Sprint/UpdateSprint.php new file mode 100644 index 0000000..c5048e0 --- /dev/null +++ b/src/php/Actions/Sprint/UpdateSprint.php @@ -0,0 +1,60 @@ + 'active'], 1); + */ +class UpdateSprint +{ + use Action; + + /** + * @param array{status?: string, title?: string, description?: string, goal?: string} $data + * + * @throws \InvalidArgumentException + */ + public function handle(string $slug, array $data, int $workspaceId): Sprint + { + if ($slug === '') { + throw new \InvalidArgumentException('slug is required'); + } + + $sprint = Sprint::forWorkspace($workspaceId) + ->where('slug', $slug) + ->first(); + + if (! $sprint) { + throw new \InvalidArgumentException("Sprint not found: {$slug}"); + } + + if (isset($data['status'])) { + $valid = [Sprint::STATUS_PLANNING, Sprint::STATUS_ACTIVE, Sprint::STATUS_COMPLETED, Sprint::STATUS_CANCELLED]; + if (! in_array($data['status'], $valid, true)) { + throw new \InvalidArgumentException( + sprintf('status must be one of: %s', implode(', ', $valid)) + ); + } + + if ($data['status'] === Sprint::STATUS_ACTIVE && ! $sprint->started_at) { + $data['started_at'] = now(); + } + + if (in_array($data['status'], [Sprint::STATUS_COMPLETED, Sprint::STATUS_CANCELLED], true)) { + $data['ended_at'] = now(); + } + } + + $sprint->update($data); + + return $sprint->fresh()->load('issues'); + } +} diff --git a/src/php/Controllers/Api/IssueController.php b/src/php/Controllers/Api/IssueController.php index a11516d..2d3819e 100644 --- a/src/php/Controllers/Api/IssueController.php +++ b/src/php/Controllers/Api/IssueController.php @@ -5,43 +5,248 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Controllers\Api; use Core\Front\Controller; +use Core\Mod\Agentic\Actions\Issue\AddIssueComment; +use Core\Mod\Agentic\Actions\Issue\ArchiveIssue; +use Core\Mod\Agentic\Actions\Issue\CreateIssue; +use Core\Mod\Agentic\Actions\Issue\GetIssue; +use Core\Mod\Agentic\Actions\Issue\ListIssues; +use Core\Mod\Agentic\Actions\Issue\UpdateIssue; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class IssueController extends Controller { + /** + * GET /api/issues + */ public function index(Request $request): JsonResponse { - return response()->json(['data' => [], 'total' => 0]); + $validated = $request->validate([ + 'status' => 'nullable|string|in:open,in_progress,review,closed', + 'type' => 'nullable|string|in:bug,feature,task,improvement', + 'priority' => 'nullable|string|in:low,normal,high,urgent', + 'sprint' => 'nullable|string', + 'label' => 'nullable|string', + 'include_closed' => 'nullable|boolean', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $issues = ListIssues::run( + $workspace->id, + $validated['status'] ?? null, + $validated['type'] ?? null, + $validated['priority'] ?? null, + $validated['sprint'] ?? null, + $validated['label'] ?? null, + (bool) ($validated['include_closed'] ?? false), + ); + + return response()->json([ + 'data' => $issues->map(fn ($issue) => [ + 'slug' => $issue->slug, + 'title' => $issue->title, + 'type' => $issue->type, + 'status' => $issue->status, + 'priority' => $issue->priority, + 'assignee' => $issue->assignee, + 'sprint' => $issue->sprint?->slug, + 'labels' => $issue->labels ?? [], + 'updated_at' => $issue->updated_at->toIso8601String(), + ])->values()->all(), + 'total' => $issues->count(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'validation_error', + 'message' => $e->getMessage(), + ], 422); + } } + /** + * GET /api/issues/{slug} + */ public function show(Request $request, string $slug): JsonResponse { - return response()->json(['data' => null], 404); + $workspace = $request->attributes->get('workspace'); + + try { + $issue = GetIssue::run($slug, $workspace->id); + + return response()->json([ + 'data' => $issue->toMcpContext(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } } + /** + * POST /api/issues + */ public function store(Request $request): JsonResponse { - return response()->json(['data' => null], 501); + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'nullable|string|max:255', + 'description' => 'nullable|string|max:10000', + 'type' => 'nullable|string|in:bug,feature,task,improvement', + 'priority' => 'nullable|string|in:low,normal,high,urgent', + 'labels' => 'nullable|array', + 'labels.*' => 'string', + 'assignee' => 'nullable|string|max:255', + 'reporter' => 'nullable|string|max:255', + 'sprint_id' => 'nullable|integer|exists:sprints,id', + 'metadata' => 'nullable|array', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $issue = CreateIssue::run($validated, $workspace->id); + + return response()->json([ + 'data' => [ + 'slug' => $issue->slug, + 'title' => $issue->title, + 'type' => $issue->type, + 'status' => $issue->status, + 'priority' => $issue->priority, + ], + ], 201); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'validation_error', + 'message' => $e->getMessage(), + ], 422); + } } + /** + * PATCH /api/issues/{slug} + */ public function update(Request $request, string $slug): JsonResponse { - return response()->json(['data' => null], 501); + $validated = $request->validate([ + 'status' => 'nullable|string|in:open,in_progress,review,closed', + 'priority' => 'nullable|string|in:low,normal,high,urgent', + 'type' => 'nullable|string|in:bug,feature,task,improvement', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string|max:10000', + 'assignee' => 'nullable|string|max:255', + 'sprint_id' => 'nullable|integer|exists:sprints,id', + 'labels' => 'nullable|array', + 'labels.*' => 'string', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $issue = UpdateIssue::run($slug, $validated, $workspace->id); + + return response()->json([ + 'data' => [ + 'slug' => $issue->slug, + 'title' => $issue->title, + 'status' => $issue->status, + 'priority' => $issue->priority, + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } } + /** + * DELETE /api/issues/{slug} + */ public function destroy(Request $request, string $slug): JsonResponse { - return response()->json(['data' => null], 501); + $request->validate([ + 'reason' => 'nullable|string|max:500', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $issue = ArchiveIssue::run($slug, $workspace->id, $request->input('reason')); + + return response()->json([ + 'data' => [ + 'slug' => $issue->slug, + 'status' => $issue->status, + 'archived_at' => $issue->archived_at?->toIso8601String(), + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } } + /** + * GET /api/issues/{slug}/comments + */ public function comments(Request $request, string $slug): JsonResponse { - return response()->json(['data' => [], 'total' => 0]); + $workspace = $request->attributes->get('workspace'); + + try { + $issue = GetIssue::run($slug, $workspace->id); + $comments = $issue->comments; + + return response()->json([ + 'data' => $comments->map(fn ($c) => $c->toMcpContext())->values()->all(), + 'total' => $comments->count(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } } + /** + * POST /api/issues/{slug}/comments + */ public function addComment(Request $request, string $slug): JsonResponse { - return response()->json(['data' => null], 501); + $validated = $request->validate([ + 'author' => 'required|string|max:255', + 'body' => 'required|string|max:10000', + 'metadata' => 'nullable|array', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $comment = AddIssueComment::run( + $slug, + $workspace->id, + $validated['author'], + $validated['body'], + $validated['metadata'] ?? null, + ); + + return response()->json([ + 'data' => $comment->toMcpContext(), + ], 201); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } } } diff --git a/src/php/Controllers/Api/SprintController.php b/src/php/Controllers/Api/SprintController.php index 052d694..a78afc0 100644 --- a/src/php/Controllers/Api/SprintController.php +++ b/src/php/Controllers/Api/SprintController.php @@ -5,33 +5,167 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Controllers\Api; use Core\Front\Controller; +use Core\Mod\Agentic\Actions\Sprint\ArchiveSprint; +use Core\Mod\Agentic\Actions\Sprint\CreateSprint; +use Core\Mod\Agentic\Actions\Sprint\GetSprint; +use Core\Mod\Agentic\Actions\Sprint\ListSprints; +use Core\Mod\Agentic\Actions\Sprint\UpdateSprint; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class SprintController extends Controller { + /** + * GET /api/sprints + */ public function index(Request $request): JsonResponse { - return response()->json(['data' => [], 'total' => 0]); + $validated = $request->validate([ + 'status' => 'nullable|string|in:planning,active,completed,cancelled', + 'include_cancelled' => 'nullable|boolean', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $sprints = ListSprints::run( + $workspace->id, + $validated['status'] ?? null, + (bool) ($validated['include_cancelled'] ?? false), + ); + + return response()->json([ + 'data' => $sprints->map(fn ($sprint) => [ + 'slug' => $sprint->slug, + 'title' => $sprint->title, + 'status' => $sprint->status, + 'progress' => $sprint->getProgress(), + 'started_at' => $sprint->started_at?->toIso8601String(), + 'ended_at' => $sprint->ended_at?->toIso8601String(), + 'updated_at' => $sprint->updated_at->toIso8601String(), + ])->values()->all(), + 'total' => $sprints->count(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'validation_error', + 'message' => $e->getMessage(), + ], 422); + } } + /** + * GET /api/sprints/{slug} + */ public function show(Request $request, string $slug): JsonResponse { - return response()->json(['data' => null], 404); + $workspace = $request->attributes->get('workspace'); + + try { + $sprint = GetSprint::run($slug, $workspace->id); + + return response()->json([ + 'data' => $sprint->toMcpContext(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } } + /** + * POST /api/sprints + */ public function store(Request $request): JsonResponse { - return response()->json(['data' => null], 501); + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'nullable|string|max:255', + 'description' => 'nullable|string|max:10000', + 'goal' => 'nullable|string|max:10000', + 'metadata' => 'nullable|array', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $sprint = CreateSprint::run($validated, $workspace->id); + + return response()->json([ + 'data' => [ + 'slug' => $sprint->slug, + 'title' => $sprint->title, + 'status' => $sprint->status, + ], + ], 201); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'validation_error', + 'message' => $e->getMessage(), + ], 422); + } } + /** + * PATCH /api/sprints/{slug} + */ public function update(Request $request, string $slug): JsonResponse { - return response()->json(['data' => null], 501); + $validated = $request->validate([ + 'status' => 'nullable|string|in:planning,active,completed,cancelled', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string|max:10000', + 'goal' => 'nullable|string|max:10000', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $sprint = UpdateSprint::run($slug, $validated, $workspace->id); + + return response()->json([ + 'data' => [ + 'slug' => $sprint->slug, + 'title' => $sprint->title, + 'status' => $sprint->status, + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } } + /** + * DELETE /api/sprints/{slug} + */ public function destroy(Request $request, string $slug): JsonResponse { - return response()->json(['data' => null], 501); + $request->validate([ + 'reason' => 'nullable|string|max:500', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $sprint = ArchiveSprint::run($slug, $workspace->id, $request->input('reason')); + + return response()->json([ + 'data' => [ + 'slug' => $sprint->slug, + 'status' => $sprint->status, + 'archived_at' => $sprint->archived_at?->toIso8601String(), + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } } }