246 lines
7.3 KiB
PHP
246 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace App\Mcp\Support;
|
|
|
|
use App\Actions\Permissions\PermissionService;
|
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
|
|
use App\Enums\Permission;
|
|
use App\Http\Resources\ProjectResource;
|
|
use App\Http\Resources\TicketResource;
|
|
use App\Models\Project;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketStatus;
|
|
use App\Models\User;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Auth\AuthenticationException;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Laravel\Mcp\Request;
|
|
use Laravel\Mcp\Response;
|
|
|
|
class KanbanGitMcp
|
|
{
|
|
public function __construct(
|
|
private readonly PermissionService $permissionService,
|
|
) {}
|
|
|
|
/**
|
|
* @throws AuthenticationException
|
|
*/
|
|
public function user(Request $request): User
|
|
{
|
|
$user = $request->user();
|
|
|
|
if (! $user instanceof User) {
|
|
throw new AuthenticationException('Authentication required.');
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* @throws AuthorizationException
|
|
*/
|
|
public function ensureCanRead(User $user, Project $project): void
|
|
{
|
|
if (! $this->permissionService->allows($user, Permission::ProjectRead, $project)) {
|
|
throw new AuthorizationException('You cannot view this project.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws AuthorizationException
|
|
*/
|
|
public function ensureCanWrite(User $user, Project $project): void
|
|
{
|
|
if (! $this->permissionService->allows($user, Permission::ProjectWrite, $project)) {
|
|
throw new AuthorizationException('You cannot update tickets in this project.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws AuthorizationException
|
|
*/
|
|
public function ticketForRead(User $user, int $ticketId): Ticket
|
|
{
|
|
$ticket = Ticket::query()
|
|
->with('project')
|
|
->find($ticketId);
|
|
|
|
if ($ticket === null) {
|
|
throw ValidationException::withMessages([
|
|
'ticket_id' => 'Ticket was not found.',
|
|
]);
|
|
}
|
|
|
|
$this->ensureCanRead($user, $ticket->project);
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
/**
|
|
* @throws AuthorizationException
|
|
*/
|
|
public function ticketForWrite(User $user, int $ticketId): Ticket
|
|
{
|
|
$ticket = Ticket::query()
|
|
->with('project')
|
|
->find($ticketId);
|
|
|
|
if ($ticket === null) {
|
|
throw ValidationException::withMessages([
|
|
'ticket_id' => 'Ticket was not found.',
|
|
]);
|
|
}
|
|
|
|
$this->ensureCanWrite($user, $ticket->project);
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
/**
|
|
* @throws AuthorizationException
|
|
*/
|
|
public function projectForRead(User $user, ?int $projectId): ?Project
|
|
{
|
|
if ($projectId === null) {
|
|
return null;
|
|
}
|
|
|
|
$project = Project::query()->find($projectId);
|
|
|
|
if ($project === null) {
|
|
throw ValidationException::withMessages([
|
|
'project_id' => 'Project was not found.',
|
|
]);
|
|
}
|
|
|
|
$this->ensureCanRead($user, $project);
|
|
|
|
return $project;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
* @return Collection<int, TicketStatus>|Response
|
|
*/
|
|
public function resolveTicketStatuses(User $user, array $validated, ?Project $project = null): Collection|Response
|
|
{
|
|
$projectIds = $project === null
|
|
? $this->permissionService->viewableProjectIds($user)
|
|
: [$project->id];
|
|
|
|
if ($projectIds === []) {
|
|
return collect();
|
|
}
|
|
|
|
$ticketStatusId = $validated['ticket_status_id'] ?? null;
|
|
if ($ticketStatusId !== null) {
|
|
$ticketStatus = TicketStatus::query()
|
|
->whereIn('project_id', $projectIds)
|
|
->with('boardColumn')
|
|
->find((int) $ticketStatusId);
|
|
|
|
return $ticketStatus === null
|
|
? Response::error('Ticket status was not found in a visible project.')
|
|
: collect([$ticketStatus]);
|
|
}
|
|
|
|
$query = TicketStatus::query()
|
|
->whereIn('project_id', $projectIds)
|
|
->with(['boardColumn', 'project'])
|
|
->orderBy('project_id')
|
|
->orderBy('position');
|
|
|
|
if (($statusSlug = $validated['status_slug'] ?? null) !== null) {
|
|
$query->where('slug', $statusSlug);
|
|
} elseif (($statusName = $validated['status_name'] ?? null) !== null) {
|
|
$query->where('name', $statusName);
|
|
} else {
|
|
return Response::error('Provide ticket_status_id, status_slug, or status_name.');
|
|
}
|
|
|
|
$ticketStatuses = $query->get();
|
|
|
|
if ($ticketStatuses->count() <= 1) {
|
|
return $ticketStatuses;
|
|
}
|
|
|
|
$matches = $ticketStatuses
|
|
->map(fn (TicketStatus $ticketStatus): array => [
|
|
'id' => $ticketStatus->id,
|
|
'name' => $ticketStatus->name,
|
|
'project_id' => $ticketStatus->project_id,
|
|
'project_name' => $ticketStatus->project?->name,
|
|
'slug' => $ticketStatus->slug,
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
return Response::error(sprintf(
|
|
'Status lookup is ambiguous. Provide ticket_status_id or project_id. Matches: %s',
|
|
json_encode($matches, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function ticketPayload(Ticket $ticket): array
|
|
{
|
|
$ticket->load([
|
|
'assignee',
|
|
'author',
|
|
'fieldValues.ticketFieldDefinition',
|
|
'fieldValues.userValue',
|
|
'project',
|
|
'ticketPriority',
|
|
'ticketStatus.boardColumn',
|
|
'ticketType',
|
|
'worklogEntries.actor',
|
|
]);
|
|
|
|
return [
|
|
'project' => ProjectResource::make($ticket->project)->resolve(),
|
|
'ticket' => TicketResource::make($ticket)->resolve(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* acceptance_criteria: array<int, array{checked: bool, indent: int, text: string}>,
|
|
* assignee_user_id: int|null,
|
|
* author_user_id: int|null,
|
|
* blocked_comment: string|null,
|
|
* description: string|null,
|
|
* is_blocked: bool,
|
|
* parent_ticket_id: int|null,
|
|
* story_points: int|null,
|
|
* ticket_priority_id: int,
|
|
* ticket_type_id: int,
|
|
* title: string
|
|
* }
|
|
*/
|
|
public function updatePayload(Ticket $ticket): array
|
|
{
|
|
return [
|
|
'acceptance_criteria' => AcceptanceCriteriaChecklist::fromModels(
|
|
$ticket->relationLoaded('acceptanceCriteria')
|
|
? $ticket->acceptanceCriteria
|
|
: $ticket->acceptanceCriteria()->get(),
|
|
$ticket->acceptance_criteria,
|
|
),
|
|
'assignee_user_id' => $ticket->assignee_user_id,
|
|
'author_user_id' => $ticket->author_user_id,
|
|
'blocked_comment' => $ticket->blocked_comment,
|
|
'description' => $ticket->description,
|
|
'is_blocked' => $ticket->is_blocked,
|
|
'parent_ticket_id' => $ticket->parent_ticket_id,
|
|
'story_points' => $ticket->story_points,
|
|
'ticket_priority_id' => $ticket->ticket_priority_id,
|
|
'ticket_type_id' => $ticket->ticket_type_id,
|
|
'title' => $ticket->title,
|
|
];
|
|
}
|
|
}
|