Files
KanbanGit/app/Mcp/Support/KanbanGitMcp.php
Twyco 9ed8d0c6cc
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m12s
CI/CD / Publish immutable image (pull_request) Skipped
feat(KAN-72): complete MCP ticket editing and discovery
2026-10-02 13:34:00 +02:00

289 lines
8.7 KiB
PHP

<?php
namespace App\Mcp\Support;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
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\Contracts\Pagination\LengthAwarePaginator;
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;
}
public function ticketForKey(User $user, string $ticketKey): Ticket
{
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
throw ValidationException::withMessages(['ticket_key' => '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;
}
/**
* @throws AuthorizationException
*/
public function projectForWrite(User $user, int $projectId): Project
{
$project = Project::query()->find($projectId);
if ($project === null) {
throw ValidationException::withMessages([
'project_id' => 'Project was not found.',
]);
}
$this->ensureCanWrite($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{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
public function paginationPayload(LengthAwarePaginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'has_more_pages' => $paginator->hasMorePages(),
];
}
/**
* @return array<string, mixed>
*/
public function ticketPayload(Ticket $ticket): array
{
$ticket->load([
'acceptanceCriteria',
'assignee',
'author',
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
'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' => AcceptanceCriteriaChecklistMapper::fromModels(
$ticket->relationLoaded('acceptanceCriteria')
? $ticket->acceptanceCriteria
: $ticket->acceptanceCriteria()->get(),
),
'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,
];
}
}