From c490d4f387ca996539a1b12a39a1510afbffa621 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 26 Sep 2026 17:08:04 -0400 Subject: [PATCH] MCP: ticket links and similar-ticket search (#113) - 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 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- README.md | 7 +- api/ticket_dependencies.php | 146 +++-------------------- mcp/server.php | 6 +- mcp/src/ToolCatalog.php | 10 +- mcp/src/Tools/TicketReadTools.php | 97 ++++++++++++++++ mcp/src/Tools/TicketWriteTools.php | 105 ++++++++++++++++- models/DependencyModel.php | 8 +- services/DependencyService.php | 180 +++++++++++++++++++++++++++++ 8 files changed, 418 insertions(+), 141 deletions(-) create mode 100644 services/DependencyService.php diff --git a/README.md b/README.md index 0cd52bb..8148dae 100644 --- a/README.md +++ b/README.md @@ -128,11 +128,14 @@ claude mcp login tinker # add --no-browser on a headless machine, the | Tool | Scope | What it does | |------|-------|--------------| | `search_tickets` | `tickets:read` | Search/list tickets you can see (text, status, priority, category, assignee `me`/`unassigned`/username), paginated | -| `get_ticket` | `tickets:read` | One ticket's details + comments | +| `get_ticket` | `tickets:read` | One ticket's details + comments, its links (Dependencies tab, phrased from this ticket's side incl. `duplicated_by`) and a `blocked` flag (any open `blocked_by`) | +| `find_similar_tickets` | `tickets:read` | Open tickets with similar titles, by title text or an existing ticket (same finder as the ticket page's possible-duplicates list) | | `create_ticket` | `tickets:write` | Create a ticket (title, description, priority, category, type, visibility, assignee) | | `add_comment` | `tickets:write` | Comment (markdown, @mentions, replies) | | `update_status` | `tickets:write` | Workflow-validated status change; `comment` required when the transition requires one | | `assign_ticket` | `tickets:write` | Assign / unassign (admin, creator, or current assignee only) | +| `link_tickets` | `tickets:write` | Add a `blocks` / `blocked_by` / `relates_to` / `duplicates` link ("A duplicates B" = A is the duplicate; links only, never closes anything) | +| `unlink_tickets` | `tickets:write` | Remove a link, whichever ticket's side it was recorded from | How it fits together (see issue #111 for the full design): - **Authelia** is the authorization server: client `tinker-tickets-mcp`, custom scopes `tickets:read`/`tickets:write`, RS256 JWT access tokens carrying `preferred_username`/`groups`. Needs Authelia **≠ 4.39.21/4.39.22** (RFC 8707 `resource` bug); 4.39.20 or ≥ 4.39.23 are fine. @@ -444,6 +447,8 @@ tinker_tickets/ ├── services/ │ ├── AssignmentService.php # Assign/unassign (shared by assign_ticket.php + MCP) │ ├── CommentService.php # Add comment + mentions/notifications (shared by add_comment.php + MCP) +│ ├── DependencyService.php # List/add/remove ticket links (shared by ticket_dependencies.php + MCP) +│ ├── SimilarTicketService.php # Similar-title finder (shared by check_duplicates.php + MCP) │ └── TicketCreationService.php # Create ticket (shared by TicketController + MCP) ├── uploads/ # File attachment storage (served only via PHP; nginx: internal) │ └── avatars/ # lldap avatar disk cache diff --git a/api/ticket_dependencies.php b/api/ticket_dependencies.php index d969156..26c123d 100644 --- a/api/ticket_dependencies.php +++ b/api/ticket_dependencies.php @@ -75,9 +75,7 @@ if (session_status() === PHP_SESSION_NONE) { require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/helpers/Database.php'; -require_once dirname(__DIR__) . '/models/DependencyModel.php'; -require_once dirname(__DIR__) . '/models/AuditLogModel.php'; -require_once dirname(__DIR__) . '/models/TicketModel.php'; +require_once dirname(__DIR__) . '/services/DependencyService.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; header('Content-Type: application/json'); @@ -87,11 +85,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { ResponseHelper::unauthorized(); } -$userId = $_SESSION['user']['user_id']; $currentUser = $_SESSION['user']; -$isAdmin = $currentUser['is_admin'] ?? false; -// users.groups is a comma-separated string; the dependency model expects an array. -$userGroups = array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? '')))); // CSRF Protection for POST/DELETE if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') { @@ -111,158 +105,48 @@ if ($tableCheck->num_rows === 0) { ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.'); } -try { - $dependencyModel = new DependencyModel($conn); - $auditLog = new AuditLogModel($conn); - $ticketModel = new TicketModel($conn); -} catch (Exception $e) { - error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage()); - ResponseHelper::serverError('Failed to initialize required components'); -} - $method = $_SERVER['REQUEST_METHOD']; try { switch ($method) { case 'GET': // Get dependencies for a ticket - $ticketId = $_GET['ticket_id'] ?? null; - - if (!$ticketId) { - ResponseHelper::error('Ticket ID required'); + $result = DependencyService::list($conn, $currentUser, $_GET['ticket_id'] ?? null); + if (!$result['success']) { + ResponseHelper::error($result['error'], $result['http_status']); } - - // Verify user can access this ticket - $ticket = $ticketModel->getTicketById($ticketId); - if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { - ResponseHelper::notFound('Ticket not found'); - } - - try { - $dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin); - $dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin); - } catch (Exception $e) { - error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage()); - ResponseHelper::serverError('Failed to retrieve dependencies'); - } - ResponseHelper::success([ - 'dependencies' => $dependencies, - 'dependents' => $dependents + 'dependencies' => $result['dependencies'], + 'dependents' => $result['dependents'] ]); break; case 'POST': // Add a new dependency $data = json_decode(file_get_contents('php://input'), true); - if (!is_array($data)) { ResponseHelper::error('Invalid JSON'); } - $ticketId = $data['ticket_id'] ?? null; - $dependsOnId = $data['depends_on_id'] ?? null; - $type = $data['dependency_type'] ?? 'blocks'; - - if (!$ticketId || !$dependsOnId) { - ResponseHelper::error('Both ticket_id and depends_on_id are required'); - } - - // Verify user can access both tickets before creating dependency - $srcTicket = $ticketModel->getTicketById($ticketId); - if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) { - ResponseHelper::notFound('Ticket not found'); - } - $tgtTicket = $ticketModel->getTicketById($dependsOnId); - if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) { - ResponseHelper::notFound('Target ticket not found'); - } - - $result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId); - - if ($result['success']) { - // Log to audit - $auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [ - 'ticket_id' => $ticketId, - 'depends_on_id' => $dependsOnId, - 'type' => $type - ]); - - ResponseHelper::created($result); - } else { - ResponseHelper::error($result['error']); + $result = DependencyService::add($conn, $currentUser, $data); + if (!$result['success']) { + ResponseHelper::error($result['error'], $result['http_status']); } + ResponseHelper::created($result); break; case 'DELETE': - // Remove a dependency + // Remove a dependency, by dependency_id or by ticket IDs + type $data = json_decode(file_get_contents('php://input'), true); - if (!is_array($data)) { ResponseHelper::error('Invalid JSON'); } - $dependencyId = $data['dependency_id'] ?? null; - - // Alternative: delete by ticket IDs - if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) { - $ticketId = $data['ticket_id']; - $dependsOnId = $data['depends_on_id']; - $type = $data['dependency_type'] ?? 'blocks'; - - // Validate dependency type - $validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates']; - if (!in_array($type, $validTypes, true)) { - ResponseHelper::error('Invalid dependency type'); - } - - // Verify user can access the source ticket - $srcTicket = $ticketModel->getTicketById($ticketId); - if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) { - ResponseHelper::notFound('Ticket not found'); - } - - $result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type); - - if ($result) { - $auditLog->log($userId, 'delete', 'dependency', null, [ - 'ticket_id' => $ticketId, - 'depends_on_id' => $dependsOnId, - 'type' => $type - ]); - ResponseHelper::success([], 'Dependency removed'); - } else { - ResponseHelper::error('Failed to remove dependency'); - } - } elseif ($dependencyId) { - // Look up dependency to verify ticket access before deletion - $depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?"; - $depLookupStmt = $conn->prepare($depLookupSql); - $depLookupStmt->bind_param("i", $dependencyId); - $depLookupStmt->execute(); - $depRow = $depLookupStmt->get_result()->fetch_assoc(); - $depLookupStmt->close(); - - if (!$depRow) { - ResponseHelper::notFound('Dependency not found'); - } - - $depTicket = $ticketModel->getTicketById($depRow['ticket_id']); - if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) { - ResponseHelper::forbidden('Access denied'); - } - - $result = $dependencyModel->removeDependency($dependencyId); - - if ($result) { - $auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId); - ResponseHelper::success([], 'Dependency removed'); - } else { - ResponseHelper::error('Failed to remove dependency'); - } - } else { - ResponseHelper::error('Dependency ID or ticket IDs required'); + $result = DependencyService::remove($conn, $currentUser, $data); + if (!$result['success']) { + ResponseHelper::error($result['error'], $result['http_status']); } + ResponseHelper::success([], 'Dependency removed'); break; default: diff --git a/mcp/server.php b/mcp/server.php index 78254c4..35042be 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -28,6 +28,8 @@ require_once dirname(__DIR__) . '/controllers/ApiTicketController.php'; require_once dirname(__DIR__) . '/services/TicketCreationService.php'; require_once dirname(__DIR__) . '/services/CommentService.php'; require_once dirname(__DIR__) . '/services/AssignmentService.php'; +require_once dirname(__DIR__) . '/services/DependencyService.php'; +require_once dirname(__DIR__) . '/services/SimilarTicketService.php'; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; @@ -138,7 +140,9 @@ $transport = new StreamableHttpTransport( new IdentityMiddleware($conn, $psr17, $psr17), new ToolScopeMiddleware( $psr17, - $canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath() + $canonical['scheme'] . '://' . $canonical['host'] + . (isset($canonical['port']) ? ':' . $canonical['port'] : '') + . $metadata->getPrimaryMetadataPath() ), ], ); diff --git a/mcp/src/ToolCatalog.php b/mcp/src/ToolCatalog.php index 41d57e0..2048290 100644 --- a/mcp/src/ToolCatalog.php +++ b/mcp/src/ToolCatalog.php @@ -15,7 +15,7 @@ use TinkerTickets\Mcp\Tools\TicketWriteTools; final class ToolCatalog { /** Tool names that mutate data and require tickets:write. */ - private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket']; + private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket', 'link_tickets', 'unlink_tickets']; public static function isWriteTool(string $name): bool { @@ -27,16 +27,20 @@ final class ToolCatalog $read = new TicketReadTools($conn); $write = new TicketWriteTools($conn); $readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false); - // Writes change tickets (and notify people) but never delete anything. + // Writes change tickets (and notify people); only unlink_tickets removes anything. $additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false); $update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false); + $remove = new ToolAnnotations(readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false); return $builder ->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly) ->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly) + ->addTool([$read, 'findSimilarTickets'], 'find_similar_tickets', 'Find similar tickets', null, $readOnly) ->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive) ->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive) ->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update) - ->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update); + ->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update) + ->addTool([$write, 'linkTickets'], 'link_tickets', 'Link tickets', null, $additive) + ->addTool([$write, 'unlinkTickets'], 'unlink_tickets', 'Unlink tickets', null, $remove); } } diff --git a/mcp/src/Tools/TicketReadTools.php b/mcp/src/Tools/TicketReadTools.php index 2a9f5cf..1ea6323 100644 --- a/mcp/src/Tools/TicketReadTools.php +++ b/mcp/src/Tools/TicketReadTools.php @@ -96,6 +96,11 @@ final class TicketReadTools /** * Get one ticket's full details and its comments. * + * `links` lists the ticket's relationships from this ticket's point of view: + * blocks, blocked_by, relates_to, duplicates (this ticket duplicates the + * other) and duplicated_by (the other ticket duplicates this one). `blocked` + * is true while any blocked_by ticket is not Closed. + * * @param string $ticket_id The ticket ID (digits only, e.g. "123456789"). * @param bool $include_comments Include the ticket's comments, newest first. * @@ -121,6 +126,12 @@ final class TicketReadTools 'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null, ]; + $details['links'] = $this->links($ticketId, $user); + $details['blocked'] = (bool)array_filter( + $details['links'], + fn(array $l) => $l['relation'] === 'blocked_by' && $l['status'] !== 'Closed' + ); + if ($include_comments) { $comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false); $details['comment_count'] = count($comments); @@ -139,6 +150,92 @@ final class TicketReadTools return $details; } + /** + * Find open tickets with titles similar to the given text, or to an + * existing ticket's title — the same check as the "possible duplicates" + * list on the ticket page. Title matching only; use search_tickets for + * keyword search over descriptions. To record a duplicate, call + * link_tickets with relation "duplicates". + * + * @param string|null $title Title text to compare against (at least 5 characters). + * @param string|null $ticket_id Or: an existing ticket whose title to compare; it is left out of the results. + * @param int $limit Maximum matches, 1-10. + * + * @return array + */ + public function findSimilarTickets(?string $title = null, ?string $ticket_id = null, int $limit = 5): array + { + $user = McpIdentity::user(); + $excludeId = null; + + if ($ticket_id !== null && trim($ticket_id) !== '') { + $ticketModel = new \TicketModel($this->conn); + $excludeId = trim($ticket_id); + $ticket = preg_match('/^\d+$/', $excludeId) ? $ticketModel->getTicketById($excludeId) : null; + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) { + throw new ToolCallException("Ticket {$excludeId} not found"); + } + $title = (string)$ticket['title']; + } + if ($title === null || strlen(trim($title)) < 5) { + throw new ToolCallException('Provide a title of at least 5 characters, or a ticket_id'); + } + + $limit = min(10, max(1, $limit)); + // One extra so excluding the ticket itself still leaves $limit matches. + $matches = \SimilarTicketService::find($this->conn, $user, $title, $limit + 1); + $matches = array_values(array_filter($matches, fn(array $m) => (string)$m['ticket_id'] !== $excludeId)); + + return [ + 'title' => trim($title), + 'matches' => array_map(fn(array $m) => [ + 'ticket_id' => (string)$m['ticket_id'], + 'title' => $m['title'], + 'status' => $m['status'], + 'priority' => (int)$m['priority'], + 'similarity' => (int)$m['similarity'], + 'url' => \UrlHelper::ticketUrl((string)$m['ticket_id']), + ], array_slice($matches, 0, $limit)), + ]; + } + + /** + * A ticket's links in both directions, each phrased from this ticket's + * side. Linked tickets the user can't see are left out by the service. + * + * @param array $user + * + * @return list> + */ + private function links(string $ticketId, array $user): array + { + $result = \DependencyService::list($this->conn, $user, $ticketId); + if (!$result['success']) { + throw new ToolCallException($result['error']); + } + + // Stored as " "; restate from this side. + $reverse = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to', 'duplicates' => 'duplicated_by']; + $rows = []; + foreach ($result['dependencies'] as $type => $deps) { + foreach ($deps as $d) { + $rows[] = [$type, (string)$d['depends_on_id'], $d]; + } + } + foreach ($result['dependents'] as $d) { + $rows[] = [$reverse[$d['dependency_type']] ?? $d['dependency_type'], (string)$d['ticket_id'], $d]; + } + + return array_map(fn(array $r) => [ + 'relation' => $r[0], + 'ticket_id' => $r[1], + 'title' => $r[2]['title'] ?? null, + 'status' => $r[2]['status'] ?? null, + 'priority' => isset($r[2]['priority']) ? (int)$r[2]['priority'] : null, + 'url' => \UrlHelper::ticketUrl($r[1]), + ], $rows); + } + /** * @param array $user */ diff --git a/mcp/src/Tools/TicketWriteTools.php b/mcp/src/Tools/TicketWriteTools.php index 2b28012..ef5f39a 100644 --- a/mcp/src/Tools/TicketWriteTools.php +++ b/mcp/src/Tools/TicketWriteTools.php @@ -8,7 +8,7 @@ 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), run as the signed-in user, so permissions, workflow + * 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). */ @@ -183,6 +183,109 @@ final class TicketWriteTools 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 */ diff --git a/models/DependencyModel.php b/models/DependencyModel.php index 8f37899..3542d48 100644 --- a/models/DependencyModel.php +++ b/models/DependencyModel.php @@ -219,14 +219,14 @@ class DependencyModel * Remove a dependency * * @param int $dependencyId Dependency ID - * @return bool Success status + * @return int|false Rows removed (0 if it no longer existed), or false on failure */ public function removeDependency($dependencyId) { $sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?"; $stmt = $this->conn->prepare($sql); $stmt->bind_param("i", $dependencyId); - $result = $stmt->execute(); + $result = $stmt->execute() ? $stmt->affected_rows : false; $stmt->close(); return $result; } @@ -237,7 +237,7 @@ class DependencyModel * @param string $ticketId Source ticket ID * @param string $dependsOnId Target ticket ID * @param string $type Dependency type - * @return bool Success status + * @return int|false Rows removed (0 if no such link), or false on failure */ public function removeDependencyByTickets($ticketId, $dependsOnId, $type) { @@ -245,7 +245,7 @@ class DependencyModel WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?"; $stmt = $this->conn->prepare($sql); $stmt->bind_param("sss", $ticketId, $dependsOnId, $type); - $result = $stmt->execute(); + $result = $stmt->execute() ? $stmt->affected_rows : false; $stmt->close(); return $result; } diff --git a/services/DependencyService.php b/services/DependencyService.php new file mode 100644 index 0000000..6507c90 --- /dev/null +++ b/services/DependencyService.php @@ -0,0 +1,180 @@ + true, 'dependencies' => [type => rows], 'dependents' => rows] + */ + public static function list(mysqli $conn, array $currentUser, $ticketId): array + { + if (!$ticketId) { + return ['success' => false, 'error' => 'Ticket ID required', 'http_status' => 400]; + } + + // Verify user can access this ticket + $ticketModel = new TicketModel($conn); + $ticket = $ticketModel->getTicketById($ticketId); + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404]; + } + + $dependencyModel = new DependencyModel($conn); + [$userId, $userGroups, $isAdmin] = self::viewer($currentUser); + try { + $dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin); + $dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin); + } catch (Exception $e) { + error_log('DependencyService::list query error: ' . $e->getMessage()); + return ['success' => false, 'error' => 'Failed to retrieve dependencies', 'http_status' => 500]; + } + + return ['success' => true, 'dependencies' => $dependencies, 'dependents' => $dependents]; + } + + /** + * @param array $data ticket_id, depends_on_id, dependency_type (default "blocks") + * @return array ['success' => true, 'dependency_id' => int] or an error + */ + public static function add(mysqli $conn, array $currentUser, array $data): array + { + $ticketId = $data['ticket_id'] ?? null; + $dependsOnId = $data['depends_on_id'] ?? null; + $type = $data['dependency_type'] ?? 'blocks'; + + if (!$ticketId || !$dependsOnId) { + return ['success' => false, 'error' => 'Both ticket_id and depends_on_id are required', 'http_status' => 400]; + } + + // Verify user can access both tickets before creating dependency + $ticketModel = new TicketModel($conn); + $srcTicket = $ticketModel->getTicketById($ticketId); + if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) { + return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404]; + } + $tgtTicket = $ticketModel->getTicketById($dependsOnId); + if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) { + return ['success' => false, 'error' => 'Target ticket not found', 'http_status' => 404]; + } + + $result = (new DependencyModel($conn))->addDependency($ticketId, $dependsOnId, $type, $currentUser['user_id']); + if (!$result['success']) { + return ['success' => false, 'error' => $result['error'], 'http_status' => 400]; + } + + (new AuditLogModel($conn))->log($currentUser['user_id'], 'create', 'dependency', (string)$result['dependency_id'], [ + 'ticket_id' => $ticketId, + 'depends_on_id' => $dependsOnId, + 'type' => $type + ]); + + return $result; + } + + /** + * Remove one link, by dependency_id or by (ticket_id, depends_on_id, dependency_type). + * + * @return array ['success' => true, 'removed' => rows deleted] or an error + */ + public static function remove(mysqli $conn, array $currentUser, array $data): array + { + $dependencyModel = new DependencyModel($conn); + $ticketModel = new TicketModel($conn); + $auditLog = new AuditLogModel($conn); + $dependencyId = $data['dependency_id'] ?? null; + + // Alternative: delete by ticket IDs + if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) { + $ticketId = $data['ticket_id']; + $dependsOnId = $data['depends_on_id']; + $type = $data['dependency_type'] ?? 'blocks'; + + // Validate dependency type + if (!in_array($type, self::TYPES, true)) { + return ['success' => false, 'error' => 'Invalid dependency type', 'http_status' => 400]; + } + + // Verify user can access the source ticket + $srcTicket = $ticketModel->getTicketById($ticketId); + if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) { + return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404]; + } + + $removed = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type); + if ($removed === false) { + return ['success' => false, 'error' => 'Failed to remove dependency', 'http_status' => 400]; + } + // Only audit a removal that happened (the web UI treats removing + // a link that is already gone as success). + if ($removed > 0) { + $auditLog->log($currentUser['user_id'], 'delete', 'dependency', null, [ + 'ticket_id' => $ticketId, + 'depends_on_id' => $dependsOnId, + 'type' => $type + ]); + } + return ['success' => true, 'removed' => $removed]; + } + + if (!$dependencyId) { + return ['success' => false, 'error' => 'Dependency ID or ticket IDs required', 'http_status' => 400]; + } + + // Look up dependency to verify ticket access before deletion + $depLookupStmt = $conn->prepare("SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?"); + $depLookupStmt->bind_param("i", $dependencyId); + $depLookupStmt->execute(); + $depRow = $depLookupStmt->get_result()->fetch_assoc(); + $depLookupStmt->close(); + + if (!$depRow) { + return ['success' => false, 'error' => 'Dependency not found', 'http_status' => 404]; + } + + $depTicket = $ticketModel->getTicketById($depRow['ticket_id']); + if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) { + return ['success' => false, 'error' => 'Access denied', 'http_status' => 403]; + } + + $removed = $dependencyModel->removeDependency($dependencyId); + if ($removed === false) { + return ['success' => false, 'error' => 'Failed to remove dependency', 'http_status' => 400]; + } + $auditLog->log($currentUser['user_id'], 'delete', 'dependency', (string)$dependencyId); + return ['success' => true, 'removed' => $removed]; + } + + /** + * users.groups is a comma-separated string; the dependency model expects an array. + * + * @return array{0:mixed,1:array,2:bool} + */ + private static function viewer(array $currentUser): array + { + return [ + $currentUser['user_id'] ?? null, + array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? '')))), + (bool)($currentUser['is_admin'] ?? false), + ]; + } +}