Compare commits

...
Author SHA1 Message Date
jared 4885848375 Add MIT license
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m20s
Lint / Deploy (push) Successful in 2s
2026-10-03 00:16:58 -04:00
jared cc160fe8ce 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
2026-09-26 17:13:41 -04:00
jaredandClaude Opus 5.5 c490d4f387 MCP: ticket links and similar-ticket search (#113)
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
2026-09-26 17:08:04 -04:00
jaredandClaude Opus 5.5 a9bc72fdcc Fix the possible-duplicates finder; share it as SimilarTicketService (#113)
check_duplicates.php's logic moves to services/SimilarTicketService.php
(used next by the MCP find_similar_tickets tool). Fixes found while
testing it:

- Non-admins never got matches: the query had no `t` alias but the
  visibility filter's SQL uses t.*, so it failed and the error was
  swallowed into "no duplicates".
- The word-overlap scoring could never fire, since candidates were only
  whole-title substring or SOUNDEX matches. Tickets sharing a significant
  word (4+ letters) are now candidates too; overlap needs 2+ shared words
  so one common word (e.g. every automated ticket's [problem] tag) isn't
  a match.
- SOUNDEX compared PHP's 4-char code (effectively the first word) with
  MariaDB's full-length code: the SQL side almost never matched, and the
  scorer marked any two titles sharing a first word as "sounds alike".
  Both sides now use SQL SOUNDEX on the full title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-26 17:08:04 -04:00
11 changed files with 592 additions and 239 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Jared Vititoe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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 !== ''
)));
}
}