*/ public function createTicket( string $title, string $description, int $priority = 4, string $category = 'General', string $type = 'Issue', string $visibility = 'public', ?string $visibility_groups = null, ?string $assignee = null, ): array { $user = McpIdentity::user(); // The web form constrains these with dropdowns; an API caller can send // anything, so validate them here before handing off. if ($priority < 1 || $priority > 5) { throw new ToolCallException('priority must be between 1 and 5'); } if (!in_array($visibility, self::VISIBILITIES, true)) { throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES)); } $assignedTo = null; if ($assignee !== null && trim($assignee) !== '') { $assignedTo = $this->resolveUserId(trim($assignee), $user); } $result = \TicketCreationService::create($this->conn, $user, [ 'title' => $title, 'description' => $description, 'priority' => (string)$priority, 'category' => $category, 'type' => $type, 'status' => 'Open', 'visibility' => $visibility, 'visibility_groups' => $visibility_groups, 'assigned_to' => $assignedTo, ]); if (!$result['success']) { throw new ToolCallException($result['error']); } return [ 'ticket_id' => (string)$result['ticket_id'], 'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']), ]; } /** * Post a comment on a ticket as you. @username mentions notify that user. * * @param string $ticket_id The ticket ID. * @param string $text The comment text. * @param bool $markdown Render the comment as markdown. * @param int|null $reply_to comment_id of the comment you are replying to, if any. * * @return array */ public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array { $user = McpIdentity::user(); $result = \CommentService::addComment($this->conn, $user, [ 'ticket_id' => $ticket_id, 'comment_text' => $text, 'markdown_enabled' => $markdown, 'parent_comment_id' => $reply_to, ]); if (empty($result['success'])) { throw new ToolCallException($this->errorMessage($result, $ticket_id)); } return [ 'comment_id' => (int)$result['comment_id'], 'ticket_id' => trim($ticket_id), 'mentions' => $result['mentions'] ?? [], ]; } /** * Change a ticket's status. Transitions follow the Workflow Designer rules; * some transitions (e.g. closing) require a comment, which is posted as the reason. * * @param string $ticket_id The ticket ID. * @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed". * @param string|null $comment Reason for the change; required for transitions that need one. * * @return array */ public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array { $user = McpIdentity::user(); $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; if (!in_array($status, $validStatuses, true)) { throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses)); } $ticketId = trim($ticket_id); if (!ctype_digit($ticketId)) { throw new ToolCallException("Ticket {$ticketId} not found"); } $controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user); $data = ['status' => $status]; if ($comment !== null && trim($comment) !== '') { $data['comment'] = $comment; $data['markdown_enabled'] = true; } $result = $controller->update($ticketId, $data); if (empty($result['success'])) { if (!empty($result['requires_comment'])) { throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.'); } throw new ToolCallException($this->errorMessage($result, $ticketId)); } // api/update_ticket.php invalidates the dashboard stats cache after a // successful update (outside the controller); do the same here. (new \StatsModel($this->conn))->invalidateCache(); return ['ticket_id' => $ticketId, 'status' => $result['status']]; } /** * Assign a ticket to someone, or unassign it. Only admins, the ticket's * creator, or its current assignee may do this. * * @param string $ticket_id The ticket ID. * @param string $assignee Username to assign to, "me", or "unassigned". * * @return array */ public function assignTicket(string $ticket_id, string $assignee): array { $user = McpIdentity::user(); $target = trim($assignee); $assignedTo = strtolower($target) === 'unassigned' || $target === '' ? null : $this->resolveUserId($target, $user); $result = \AssignmentService::assign($this->conn, $user, [ 'ticket_id' => $ticket_id, 'assigned_to' => $assignedTo, ]); if (empty($result['success'])) { throw new ToolCallException($this->errorMessage($result, $ticket_id)); } return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target]; } /** * Link two tickets, as shown on the ticket page's Dependencies tab. Read it * as "ticket_id other_ticket_id": * - blocks: ticket_id must be done before other_ticket_id. * - blocked_by: ticket_id is waiting on other_ticket_id. * - relates_to: the tickets are related. * - duplicates: ticket_id is a duplicate of other_ticket_id (the original). * This only records the link; close the duplicate separately with * update_status if that's wanted. * Circular blocking chains and links that already exist (from either side) * are rejected. * * @param string $ticket_id The ticket the relation is stated from. * @param string $relation One of: blocks, blocked_by, relates_to, duplicates. * @param string $other_ticket_id The other ticket. * * @return array */ public function linkTickets(string $ticket_id, string $relation, string $other_ticket_id): array { $user = McpIdentity::user(); [$ticketId, $otherId, $relation] = $this->linkArgs($ticket_id, $relation, $other_ticket_id); $result = \DependencyService::add($this->conn, $user, [ 'ticket_id' => $ticketId, 'depends_on_id' => $otherId, 'dependency_type' => $relation, ]); if (empty($result['success'])) { if (($result['error'] ?? '') === 'Target ticket not found') { throw new ToolCallException("Ticket {$otherId} not found"); } throw new ToolCallException($this->errorMessage($result, $ticketId)); } return ['ticket_id' => $ticketId, 'relation' => $relation, 'other_ticket_id' => $otherId]; } /** * Remove a link between two tickets. Takes the same arguments as * link_tickets; a link recorded from the other ticket's side ("B blocked_by A" * for "A blocks B") is found and removed too. * * @param string $ticket_id The ticket the relation is stated from. * @param string $relation One of: blocks, blocked_by, relates_to, duplicates. * @param string $other_ticket_id The other ticket. * * @return array */ public function unlinkTickets(string $ticket_id, string $relation, string $other_ticket_id): array { $user = McpIdentity::user(); [$ticketId, $otherId, $relation] = $this->linkArgs($ticket_id, $relation, $other_ticket_id); // The same relationship can be stored from either ticket. duplicates // has no inverse type, so it is only ever stored one way. $inverse = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to']; $attempts = [[$ticketId, $otherId, $relation]]; if (isset($inverse[$relation])) { $attempts[] = [$otherId, $ticketId, $inverse[$relation]]; } $removed = 0; foreach ($attempts as [$from, $to, $type]) { $result = \DependencyService::remove($this->conn, $user, [ 'ticket_id' => $from, 'depends_on_id' => $to, 'dependency_type' => $type, ]); if (empty($result['success'])) { throw new ToolCallException($this->errorMessage($result, $from)); } $removed += (int)$result['removed']; } if ($removed === 0) { throw new ToolCallException("No {$relation} link between {$ticketId} and {$otherId}"); } return ['ticket_id' => $ticketId, 'relation' => $relation, 'other_ticket_id' => $otherId, 'removed' => true]; } /** * @return array{0:string,1:string,2:string} */ private function linkArgs(string $ticketId, string $relation, string $otherId): array { $ticketId = trim($ticketId); $otherId = trim($otherId); $relation = strtolower(trim($relation)); if (!in_array($relation, \DependencyService::TYPES, true)) { throw new ToolCallException('relation must be one of: ' . implode(', ', \DependencyService::TYPES)); } foreach ([$ticketId, $otherId] as $id) { if (!ctype_digit($id)) { throw new ToolCallException("Ticket {$id} not found"); } } if ($ticketId === $otherId) { throw new ToolCallException('A ticket cannot be linked to itself'); } return [$ticketId, $otherId, $relation]; } /** * @param array $user */ private function resolveUserId(string $username, array $user): int { if (strtolower($username) === 'me') { return (int)$user['user_id']; } $match = (new \UserModel($this->conn))->getUserByUsername($username); if (!$match) { throw new ToolCallException("Unknown user: {$username}"); } return (int)$match['user_id']; } /** * Map a shared-service failure to a tool error. A ticket the user can't * see reads as "not found" (like get_ticket), never "access denied", so a * restricted ticket's existence isn't revealed. * * @param array $result */ private function errorMessage(array $result, string $ticketId): string { $error = (string)($result['error'] ?? 'Request failed'); if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) { return 'Ticket ' . trim($ticketId) . ' not found'; } return $error; } }