Merge development: MCP ticket links + duplicate finder fixes (#113)
Lint / PHP (phpcs PSR-12) (push) Successful in 52s
Lint / JS (eslint) (push) Successful in 20s
Lint / PHP requirements (version + extensions) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m13s
Lint / Deploy (push) Successful in 4s

This commit is contained in:
2026-09-26 17:13:41 -04:00
10 changed files with 571 additions and 239 deletions
+6 -1
View File
@@ -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
+2 -98
View File
@@ -8,7 +8,7 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/services/SimilarTicketService.php';
// Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
@@ -18,100 +18,4 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
// Get title parameter
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
if (strlen($title) < 5) {
ResponseHelper::success(['duplicates' => []]);
}
// Search for similar titles
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
$duplicates = [];
// Prepare search term for LIKE
$searchTerm = '%' . $title . '%';
// Get SOUNDEX of title
$soundexTitle = soundex($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// First, search for exact substring matches (case-insensitive)
$sql = "SELECT ticket_id, title, status, priority, created_at
FROM tickets
WHERE (
title LIKE ?
OR SOUNDEX(title) = ?
)
AND status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY created_at DESC
LIMIT 10";
$types = "ss" . $visFilter['types'];
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
// Duplicate detection is advisory (it must not block ticket creation), so on any
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
// mysqli may throw (default exception mode) or return false depending on config.
try {
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new RuntimeException('prepare failed: ' . $conn->error);
}
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
if ($result === false) {
// Non-exception mysqli mode: execute/get_result return false instead of
// throwing. Treat as a query failure so we don't fatal on $result below.
throw new RuntimeException('query failed: ' . $conn->error);
}
} catch (Throwable $e) {
error_log('check_duplicates: ' . $e->getMessage());
ResponseHelper::success(['duplicates' => []]);
}
while ($row = $result->fetch_assoc()) {
// Calculate similarity score
$similarity = 0;
// Check for exact substring match
if (stripos($row['title'], $title) !== false) {
$similarity = 90;
// Check SOUNDEX match
} elseif (soundex($row['title']) === $soundexTitle) {
$similarity = 70;
// Check word overlap
} else {
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
$matchingWords = array_intersect($titleWords, $rowWords);
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
}
if ($similarity >= 30) {
$duplicates[] = [
'ticket_id' => $row['ticket_id'],
'title' => $row['title'],
'status' => $row['status'],
'priority' => $row['priority'],
'created_at' => $row['created_at'],
'similarity' => round($similarity)
];
}
}
$stmt->close();
// Sort by similarity descending
usort($duplicates, function ($a, $b) {
return $b['similarity'] - $a['similarity'];
});
// Limit to top 5
$duplicates = array_slice($duplicates, 0, 5);
ResponseHelper::success(['duplicates' => $duplicates]);
ResponseHelper::success(['duplicates' => SimilarTicketService::find($conn, $currentUser, $title)]);
+15 -131
View File
@@ -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:
+5 -1
View File
@@ -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()
),
],
);
+7 -3
View File
@@ -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);
}
}
+97
View File
@@ -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<string, mixed>
*/
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<string, mixed> $user
*
* @return list<array<string, mixed>>
*/
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 "<dependent> <type> <this ticket>"; 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<string, mixed> $user
*/
+104 -1
View File
@@ -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 <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
*/
+4 -4
View File
@@ -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;
}
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* Listing, adding and removing ticket dependencies (blocks / blocked_by /
* relates_to / duplicates): access checks on the tickets involved,
* DependencyModel's duplicate/inverse/cycle checks, and audit logging.
*
* Shared by the web UI (api/ticket_dependencies.php) and the MCP
* link_tickets / unlink_tickets / get_ticket tools so both run one code path.
* Extracted from ticket_dependencies.php with the same checks, order and
* error messages. Returns result arrays; failures carry 'http_status' for
* HTTP callers. Callers own sessions/CSRF/responses.
*/
require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
class DependencyService
{
public const TYPES = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
/**
* Links from and to a ticket, limited to linked tickets the user can see.
*
* @param array $currentUser Authenticated user row (user_id, groups, is_admin)
* @return array ['success' => 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),
];
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
/**
* Finding open tickets whose titles look like a given title (LIKE + SOUNDEX +
* word overlap), limited to tickets the user can see, best match first.
*
* Shared by the web UI (api/check_duplicates.php, the ticket page's
* "possible duplicates" list) and the MCP find_similar_tickets tool.
* Extracted from check_duplicates.php.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
class SimilarTicketService
{
/**
* @param array $currentUser Authenticated user row (for visibility)
* @return array Matches: ticket_id, title, status, priority, created_at, similarity (0-100)
*/
public static function find(mysqli $conn, array $currentUser, string $title, int $limit = 5): array
{
$title = trim($title);
if (strlen($title) < 5) {
return [];
}
// Search for similar titles
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
$duplicates = [];
// Prepare search term for LIKE
$searchTerm = '%' . $title . '%';
$titleWords = self::words($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// Candidates: the whole title as a substring, a SOUNDEX match, or any
// significant word (4+ letters) in common. The scoring below decides
// what counts as similar; without the word candidates its
// word-overlap branch could never match anything, so e.g. "Printer
// jammed again" never surfaced "Printer is jammed".
$words = array_slice(array_values(array_filter($titleWords, fn($w) => mb_strlen($w) >= 4)), 0, 8);
$wordSql = str_repeat(' OR t.title LIKE ?', count($words));
$wordParams = array_map(fn($w) => '%' . addcslashes($w, '%_\\') . '%', $words);
// Aliased as `t`: the visibility filter's SQL refers to t.* columns.
// Without the alias the query failed for every non-admin and the
// error was swallowed below, so they never saw any matches.
// SOUNDEX is compared in SQL on both sides: PHP's soundex() keeps only
// 4 characters (effectively the first word), which never equalled
// MariaDB's full-length code in the WHERE and, in the scoring, made any
// two titles sharing a first word score as "sounds alike".
$sql = "SELECT t.ticket_id, t.title, t.status, t.priority, t.created_at,
SOUNDEX(t.title) = SOUNDEX(?) AS sounds_alike
FROM tickets t
WHERE (
t.title LIKE ?
OR SOUNDEX(t.title) = SOUNDEX(?){$wordSql}
)
AND t.status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY t.created_at DESC
LIMIT 50";
$types = "sss" . str_repeat('s', count($words)) . $visFilter['types'];
$params = array_merge([$title, $searchTerm, $title], $wordParams, $visFilter['params']);
// Duplicate detection is advisory (it must not block ticket creation), so on any
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
// mysqli may throw (default exception mode) or return false depending on config.
try {
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new RuntimeException('prepare failed: ' . $conn->error);
}
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
if ($result === false) {
// Non-exception mysqli mode: execute/get_result return false instead of
// throwing. Treat as a query failure so we don't fatal on $result below.
throw new RuntimeException('query failed: ' . $conn->error);
}
} catch (Throwable $e) {
error_log('check_duplicates: ' . $e->getMessage());
return [];
}
while ($row = $result->fetch_assoc()) {
// Calculate similarity score
$similarity = 0;
// Check for exact substring match
if (stripos($row['title'], $title) !== false) {
$similarity = 90;
// Check SOUNDEX match
} elseif (!empty($row['sounds_alike'])) {
$similarity = 70;
// Check word overlap
} else {
// At least two shared words: one common word (e.g. the
// "[problem]" tag every automated ticket carries) is not
// similarity, however short the searched title is.
$matchingWords = array_intersect($titleWords, self::words($row['title']));
if (count($matchingWords) >= 2) {
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
}
}
if ($similarity >= 30) {
$duplicates[] = [
'ticket_id' => $row['ticket_id'],
'title' => $row['title'],
'status' => $row['status'],
'priority' => $row['priority'],
'created_at' => $row['created_at'],
'similarity' => round($similarity)
];
}
}
$stmt->close();
// Sort by similarity descending
usort($duplicates, function ($a, $b) {
return $b['similarity'] - $a['similarity'];
});
// Keep the best matches
return array_slice($duplicates, 0, $limit);
}
/**
* Distinct lowercase words of a title, split on anything that isn't a
* letter or digit (so "[ceph]" and "ceph" are the same word).
*
* @return list<string>
*/
private static function words(string $title): array
{
return array_values(array_unique(array_filter(
preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($title)),
fn($w) => $w !== ''
)));
}
}