feat(issues): phase 3 — Actions and API controllers for issues and sprints
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 <virgil@lethean.io>
This commit is contained in:
parent
225b0b4812
commit
5a0b126f51
13 changed files with 976 additions and 12 deletions
53
src/php/Actions/Issue/AddIssueComment.php
Normal file
53
src/php/Actions/Issue/AddIssueComment.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Issue;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Issue;
|
||||
use Core\Mod\Agentic\Models\IssueComment;
|
||||
|
||||
/**
|
||||
* Add a comment to an issue.
|
||||
*
|
||||
* Usage:
|
||||
* $comment = AddIssueComment::run('fix-login-bug', 1, 'claude', 'Investigating root cause.');
|
||||
*/
|
||||
class AddIssueComment
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $slug, int $workspaceId, string $author, string $body, ?array $metadata = null): IssueComment
|
||||
{
|
||||
if ($slug === '') {
|
||||
throw new \InvalidArgumentException('slug is required');
|
||||
}
|
||||
|
||||
if ($author === '') {
|
||||
throw new \InvalidArgumentException('author is required');
|
||||
}
|
||||
|
||||
if ($body === '') {
|
||||
throw new \InvalidArgumentException('body is required');
|
||||
}
|
||||
|
||||
$issue = Issue::forWorkspace($workspaceId)
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
src/php/Actions/Issue/ArchiveIssue.php
Normal file
41
src/php/Actions/Issue/ArchiveIssue.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Issue;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Issue;
|
||||
|
||||
/**
|
||||
* Archive an issue.
|
||||
*
|
||||
* Usage:
|
||||
* $issue = ArchiveIssue::run('fix-login-bug', 1, 'Duplicate of #42');
|
||||
*/
|
||||
class ArchiveIssue
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $slug, int $workspaceId, ?string $reason = null): 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}");
|
||||
}
|
||||
|
||||
$issue->archive($reason);
|
||||
|
||||
return $issue->fresh();
|
||||
}
|
||||
}
|
||||
79
src/php/Actions/Issue/CreateIssue.php
Normal file
79
src/php/Actions/Issue/CreateIssue.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Issue;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Issue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Create a new issue.
|
||||
*
|
||||
* Usage:
|
||||
* $issue = CreateIssue::run(['title' => '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');
|
||||
}
|
||||
}
|
||||
40
src/php/Actions/Issue/GetIssue.php
Normal file
40
src/php/Actions/Issue/GetIssue.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Issue;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Issue;
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific issue.
|
||||
*
|
||||
* Usage:
|
||||
* $issue = GetIssue::run('fix-login-bug-abc123', 1);
|
||||
*/
|
||||
class GetIssue
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $slug, int $workspaceId): Issue
|
||||
{
|
||||
if ($slug === '') {
|
||||
throw new \InvalidArgumentException('slug is required');
|
||||
}
|
||||
|
||||
$issue = Issue::with(['sprint', 'comments'])
|
||||
->forWorkspace($workspaceId)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (! $issue) {
|
||||
throw new \InvalidArgumentException("Issue not found: {$slug}");
|
||||
}
|
||||
|
||||
return $issue;
|
||||
}
|
||||
}
|
||||
91
src/php/Actions/Issue/ListIssues.php
Normal file
91
src/php/Actions/Issue/ListIssues.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Issue;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Issue;
|
||||
use Core\Mod\Agentic\Models\Sprint;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* List issues for a workspace with optional filtering.
|
||||
*
|
||||
* Usage:
|
||||
* $issues = ListIssues::run(1);
|
||||
* $issues = ListIssues::run(1, status: 'open', type: 'bug');
|
||||
*/
|
||||
class ListIssues
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return Collection<int, Issue>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
76
src/php/Actions/Issue/UpdateIssue.php
Normal file
76
src/php/Actions/Issue/UpdateIssue.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Issue;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Issue;
|
||||
|
||||
/**
|
||||
* Update an issue's fields.
|
||||
*
|
||||
* Usage:
|
||||
* $issue = UpdateIssue::run('fix-login-bug', ['status' => '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');
|
||||
}
|
||||
}
|
||||
41
src/php/Actions/Sprint/ArchiveSprint.php
Normal file
41
src/php/Actions/Sprint/ArchiveSprint.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sprint;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Sprint;
|
||||
|
||||
/**
|
||||
* Archive (cancel) a sprint.
|
||||
*
|
||||
* Usage:
|
||||
* $sprint = ArchiveSprint::run('sprint-1', 1, 'Scope changed');
|
||||
*/
|
||||
class ArchiveSprint
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $slug, int $workspaceId, ?string $reason = null): 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}");
|
||||
}
|
||||
|
||||
$sprint->cancel($reason);
|
||||
|
||||
return $sprint->fresh();
|
||||
}
|
||||
}
|
||||
56
src/php/Actions/Sprint/CreateSprint.php
Normal file
56
src/php/Actions/Sprint/CreateSprint.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sprint;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Sprint;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Create a new sprint.
|
||||
*
|
||||
* Usage:
|
||||
* $sprint = CreateSprint::run(['title' => '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'] ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
src/php/Actions/Sprint/GetSprint.php
Normal file
40
src/php/Actions/Sprint/GetSprint.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sprint;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Sprint;
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific sprint.
|
||||
*
|
||||
* Usage:
|
||||
* $sprint = GetSprint::run('sprint-1-abc123', 1);
|
||||
*/
|
||||
class GetSprint
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $slug, int $workspaceId): Sprint
|
||||
{
|
||||
if ($slug === '') {
|
||||
throw new \InvalidArgumentException('slug is required');
|
||||
}
|
||||
|
||||
$sprint = Sprint::with('issues')
|
||||
->forWorkspace($workspaceId)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (! $sprint) {
|
||||
throw new \InvalidArgumentException("Sprint not found: {$slug}");
|
||||
}
|
||||
|
||||
return $sprint;
|
||||
}
|
||||
}
|
||||
48
src/php/Actions/Sprint/ListSprints.php
Normal file
48
src/php/Actions/Sprint/ListSprints.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sprint;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Sprint;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* List sprints for a workspace with optional filtering.
|
||||
*
|
||||
* Usage:
|
||||
* $sprints = ListSprints::run(1);
|
||||
* $sprints = ListSprints::run(1, 'active');
|
||||
*/
|
||||
class ListSprints
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return Collection<int, Sprint>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
60
src/php/Actions/Sprint/UpdateSprint.php
Normal file
60
src/php/Actions/Sprint/UpdateSprint.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sprint;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\Sprint;
|
||||
|
||||
/**
|
||||
* Update a sprint's fields.
|
||||
*
|
||||
* Usage:
|
||||
* $sprint = UpdateSprint::run('sprint-1', ['status' => '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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue