Lint / PHP (phpcs PSR-12) (push) Successful in 48s
Lint / JS (eslint) (push) Successful in 17s
Lint / PHP requirements (version + extensions) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m15s
Lint / Deploy (push) Successful in 4s
- get_ticket now returns `links` (blocks / blocked_by / relates_to /
duplicates / duplicated_by, phrased from this ticket's side, limited to
linked tickets the user can see) and `blocked` (any open blocked_by).
- find_similar_tickets (tickets:read): the possible-duplicates finder, by
title or by an existing ticket (which is excluded from the results).
- link_tickets / unlink_tickets (tickets:write). Marking a duplicate only
records the link. unlink also finds a link stored from the other side
("B blocked_by A" for "A blocks B").
api/ticket_dependencies.php's list/add/remove logic moves to
services/DependencyService.php, used by both (same checks and messages).
DependencyModel's remove methods now return rows removed, so removing a
link that is already gone no longer writes a "deleted" audit row.
Also includes the port in ToolScopeMiddleware's resource_metadata URL
(matches the 401's; no effect on prod, which has no port).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
320 lines
13 KiB
PHP
320 lines
13 KiB
PHP
<?php
|
|
|
|
namespace TinkerTickets\Mcp\Tools;
|
|
|
|
use Mcp\Exception\ToolCallException;
|
|
use TinkerTickets\Mcp\Auth\McpIdentity;
|
|
|
|
/**
|
|
* Write tools. Each one is a thin adapter over the exact code path the web UI
|
|
* uses (TicketCreationService, CommentService, ApiTicketController,
|
|
* AssignmentService, DependencyService), run as the signed-in user, so permissions, workflow
|
|
* rules, audit entries, notifications and stats-cache invalidation are
|
|
* identical. Gated by tickets:write in ToolScopeMiddleware (see ToolCatalog).
|
|
*/
|
|
final class TicketWriteTools
|
|
{
|
|
private const VISIBILITIES = ['public', 'internal', 'confidential'];
|
|
|
|
public function __construct(private readonly \mysqli $conn)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Create a new ticket as you. It starts in the Open status.
|
|
*
|
|
* @param string $title Short summary of the issue.
|
|
* @param string $description Full description (markdown supported).
|
|
* @param int $priority 1 (critical) to 5 (minimal). Default 4.
|
|
* @param string $category Ticket category, e.g. "General", "Hardware", "Network".
|
|
* @param string $type Ticket type, e.g. "Issue", "Task", "Request".
|
|
* @param string $visibility "public" (everyone), "internal" (only the listed groups), or "confidential" (only you, the assignee and admins).
|
|
* @param string|null $visibility_groups Comma-separated group names; required when visibility is "internal".
|
|
* @param string|null $assignee Username to assign to, or "me". Omit to leave unassigned.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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 <relation> 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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed> $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<string, mixed> $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;
|
|
}
|
|
}
|