Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f684626c | ||
|
|
051ef6c915 | ||
|
|
13a0612f23 | ||
|
|
4885848375 | ||
|
|
cc160fe8ce | ||
|
|
c490d4f387 | ||
|
|
a9bc72fdcc |
@@ -9,3 +9,4 @@ uploads/*
|
|||||||
!uploads/.htaccess
|
!uploads/.htaccess
|
||||||
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
|
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
|
||||||
vendor/
|
vendor/
|
||||||
|
scripts/demo/__pycache__/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -8,6 +8,62 @@ A feature-rich PHP-based ticketing system designed for tracking and managing dat
|
|||||||
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||||
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Running a six-node Proxmox/Ceph cluster generates a steady stream of small problems: a drive with SMART warnings, a link that flaps, a certificate about to expire. Off-the-shelf helpdesks are heavyweight for that, and a plain todo list loses the history. Tinker Tickets is a small, opinionated ticketing system built for exactly this: tickets are created **automatically by monitoring** (and by hand), deduplicated so a repeating alert updates one ticket instead of flooding the queue, and every change is audited.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
hw["hwmonDaemon<br/>SMART, Ceph, temps"] -->|Bearer API| tt
|
||||||
|
gd["GANDALF<br/>network regressions"] -->|Bearer API| tt
|
||||||
|
mcp["MCP server<br/>Claude Code / agents (OAuth)"] --> tt
|
||||||
|
ui["Web UI<br/>people"] --> tt
|
||||||
|
tt{{"Tinker Tickets<br/>PHP + MariaDB"}} -->|webhook| mx["Matrix notifications"]
|
||||||
|
tt --> audit[("Audit log<br/>timeline per ticket")]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gallery
|
||||||
|
|
||||||
|
**Dashboard**: live stat cards, priority and status breakdowns, team workload and a filterable queue.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Kanban view** with per-column counts and priority-coloured cards:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Ticket detail** with a live P1/P2 SLA timer, full metadata and a tabbed workspace:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Comments (Markdown, @mentions) | Dependencies (blocks / relates to) |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
**Activity timeline**, derived from the audit log (creation, assignment, status changes, comments):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Command palette** (`Ctrl+K`) jumps to pages, saved filters and recently viewed tickets:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Admin tools:**
|
||||||
|
|
||||||
|
| Workflow designer | API keys (scoped, revocable) |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| **Audit log** | **Recurring tickets** |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
**Light theme** (the whole app follows the [web_template](https://code.lotusguild.org/LotusGuild/web_template) theme toggle):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<sub>Screenshots are generated with Playwright against a throwaway local instance seeded with fictional demo data (`scripts/demo/`).</sub>
|
||||||
|
|
||||||
## Styling & Layout
|
## Styling & Layout
|
||||||
|
|
||||||
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
||||||
@@ -128,11 +184,14 @@ claude mcp login tinker # add --no-browser on a headless machine, the
|
|||||||
| Tool | Scope | What it does |
|
| Tool | Scope | What it does |
|
||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| `search_tickets` | `tickets:read` | Search/list tickets you can see (text, status, priority, category, assignee `me`/`unassigned`/username), paginated |
|
| `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) |
|
| `create_ticket` | `tickets:write` | Create a ticket (title, description, priority, category, type, visibility, assignee) |
|
||||||
| `add_comment` | `tickets:write` | Comment (markdown, @mentions, replies) |
|
| `add_comment` | `tickets:write` | Comment (markdown, @mentions, replies) |
|
||||||
| `update_status` | `tickets:write` | Workflow-validated status change; `comment` required when the transition requires one |
|
| `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) |
|
| `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):
|
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.
|
- **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 +503,8 @@ tinker_tickets/
|
|||||||
├── services/
|
├── services/
|
||||||
│ ├── AssignmentService.php # Assign/unassign (shared by assign_ticket.php + MCP)
|
│ ├── AssignmentService.php # Assign/unassign (shared by assign_ticket.php + MCP)
|
||||||
│ ├── CommentService.php # Add comment + mentions/notifications (shared by add_comment.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)
|
│ └── TicketCreationService.php # Create ticket (shared by TicketController + MCP)
|
||||||
├── uploads/ # File attachment storage (served only via PHP; nginx: internal)
|
├── uploads/ # File attachment storage (served only via PHP; nginx: internal)
|
||||||
│ └── avatars/ # lldap avatar disk cache
|
│ └── avatars/ # lldap avatar disk cache
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.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
|
// Only accept GET requests
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
@@ -18,100 +18,4 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|||||||
// Get title parameter
|
// Get title parameter
|
||||||
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
|
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
|
||||||
|
|
||||||
if (strlen($title) < 5) {
|
ResponseHelper::success(['duplicates' => SimilarTicketService::find($conn, $currentUser, $title)]);
|
||||||
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]);
|
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ if (session_status() === PHP_SESSION_NONE) {
|
|||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
require_once dirname(__DIR__) . '/services/DependencyService.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|
||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -87,11 +85,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|||||||
ResponseHelper::unauthorized();
|
ResponseHelper::unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
$userId = $_SESSION['user']['user_id'];
|
|
||||||
$currentUser = $_SESSION['user'];
|
$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
|
// CSRF Protection for POST/DELETE
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === '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.');
|
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'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($method) {
|
switch ($method) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
// Get dependencies for a ticket
|
// Get dependencies for a ticket
|
||||||
$ticketId = $_GET['ticket_id'] ?? null;
|
$result = DependencyService::list($conn, $currentUser, $_GET['ticket_id'] ?? null);
|
||||||
|
if (!$result['success']) {
|
||||||
if (!$ticketId) {
|
ResponseHelper::error($result['error'], $result['http_status']);
|
||||||
ResponseHelper::error('Ticket ID required');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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([
|
ResponseHelper::success([
|
||||||
'dependencies' => $dependencies,
|
'dependencies' => $result['dependencies'],
|
||||||
'dependents' => $dependents
|
'dependents' => $result['dependents']
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'POST':
|
case 'POST':
|
||||||
// Add a new dependency
|
// Add a new dependency
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
ResponseHelper::error('Invalid JSON');
|
ResponseHelper::error('Invalid JSON');
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = $data['ticket_id'] ?? null;
|
$result = DependencyService::add($conn, $currentUser, $data);
|
||||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
if (!$result['success']) {
|
||||||
$type = $data['dependency_type'] ?? 'blocks';
|
ResponseHelper::error($result['error'], $result['http_status']);
|
||||||
|
|
||||||
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']);
|
|
||||||
}
|
}
|
||||||
|
ResponseHelper::created($result);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'DELETE':
|
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);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
ResponseHelper::error('Invalid JSON');
|
ResponseHelper::error('Invalid JSON');
|
||||||
}
|
}
|
||||||
|
|
||||||
$dependencyId = $data['dependency_id'] ?? null;
|
$result = DependencyService::remove($conn, $currentUser, $data);
|
||||||
|
if (!$result['success']) {
|
||||||
// Alternative: delete by ticket IDs
|
ResponseHelper::error($result['error'], $result['http_status']);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
ResponseHelper::success([], 'Dependency removed');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 204 KiB |
@@ -28,6 +28,8 @@ require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
|
|||||||
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
|
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
|
||||||
require_once dirname(__DIR__) . '/services/CommentService.php';
|
require_once dirname(__DIR__) . '/services/CommentService.php';
|
||||||
require_once dirname(__DIR__) . '/services/AssignmentService.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 Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
||||||
use Mcp\Server;
|
use Mcp\Server;
|
||||||
@@ -138,7 +140,9 @@ $transport = new StreamableHttpTransport(
|
|||||||
new IdentityMiddleware($conn, $psr17, $psr17),
|
new IdentityMiddleware($conn, $psr17, $psr17),
|
||||||
new ToolScopeMiddleware(
|
new ToolScopeMiddleware(
|
||||||
$psr17,
|
$psr17,
|
||||||
$canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath()
|
$canonical['scheme'] . '://' . $canonical['host']
|
||||||
|
. (isset($canonical['port']) ? ':' . $canonical['port'] : '')
|
||||||
|
. $metadata->getPrimaryMetadataPath()
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use TinkerTickets\Mcp\Tools\TicketWriteTools;
|
|||||||
final class ToolCatalog
|
final class ToolCatalog
|
||||||
{
|
{
|
||||||
/** Tool names that mutate data and require tickets:write. */
|
/** 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
|
public static function isWriteTool(string $name): bool
|
||||||
{
|
{
|
||||||
@@ -27,16 +27,20 @@ final class ToolCatalog
|
|||||||
$read = new TicketReadTools($conn);
|
$read = new TicketReadTools($conn);
|
||||||
$write = new TicketWriteTools($conn);
|
$write = new TicketWriteTools($conn);
|
||||||
$readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false);
|
$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);
|
$additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false);
|
||||||
$update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, 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
|
return $builder
|
||||||
->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
|
->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
|
||||||
->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', 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, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive)
|
||||||
->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive)
|
->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive)
|
||||||
->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update)
|
->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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ final class TicketReadTools
|
|||||||
/**
|
/**
|
||||||
* Get one ticket's full details and its comments.
|
* 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 string $ticket_id The ticket ID (digits only, e.g. "123456789").
|
||||||
* @param bool $include_comments Include the ticket's comments, newest first.
|
* @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,
|
'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) {
|
if ($include_comments) {
|
||||||
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
|
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
|
||||||
$details['comment_count'] = count($comments);
|
$details['comment_count'] = count($comments);
|
||||||
@@ -139,6 +150,92 @@ final class TicketReadTools
|
|||||||
return $details;
|
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
|
* @param array<string, mixed> $user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
* Write tools. Each one is a thin adapter over the exact code path the web UI
|
||||||
* uses (TicketCreationService, CommentService, ApiTicketController,
|
* 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
|
* rules, audit entries, notifications and stats-cache invalidation are
|
||||||
* identical. Gated by tickets:write in ToolScopeMiddleware (see ToolCatalog).
|
* 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];
|
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
|
* @param array<string, mixed> $user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -141,9 +141,12 @@ foreach ($pending as $file) {
|
|||||||
// DDL.
|
// DDL.
|
||||||
try {
|
try {
|
||||||
// Split by semicolon but respect statements properly
|
// Split by semicolon but respect statements properly
|
||||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
// Note: This doesn't handle semicolons in strings, but our migrations are simple.
|
||||||
|
// Full-line "--" comments are stripped first so a ";" inside a comment
|
||||||
|
// can't cut a statement in half.
|
||||||
|
$sqlNoComments = preg_replace('/^\s*--.*$/m', '', $sql);
|
||||||
$statements = array_values(array_filter(
|
$statements = array_values(array_filter(
|
||||||
array_map('trim', explode(';', $sql)),
|
array_map('trim', explode(';', $sqlNoComments)),
|
||||||
function($stmt) {
|
function($stmt) {
|
||||||
// Remove comments and check if there's actual SQL
|
// Remove comments and check if there's actual SQL
|
||||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||||
|
|||||||
@@ -219,14 +219,14 @@ class DependencyModel
|
|||||||
* Remove a dependency
|
* Remove a dependency
|
||||||
*
|
*
|
||||||
* @param int $dependencyId Dependency ID
|
* @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)
|
public function removeDependency($dependencyId)
|
||||||
{
|
{
|
||||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $dependencyId);
|
$stmt->bind_param("i", $dependencyId);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute() ? $stmt->affected_rows : false;
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -237,7 +237,7 @@ class DependencyModel
|
|||||||
* @param string $ticketId Source ticket ID
|
* @param string $ticketId Source ticket ID
|
||||||
* @param string $dependsOnId Target ticket ID
|
* @param string $dependsOnId Target ticket ID
|
||||||
* @param string $type Dependency type
|
* @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)
|
public function removeDependencyByTickets($ticketId, $dependsOnId, $type)
|
||||||
{
|
{
|
||||||
@@ -245,7 +245,7 @@ class DependencyModel
|
|||||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute() ? $stmt->affected_rows : false;
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Demo environment (for README screenshots)
|
||||||
|
|
||||||
|
Everything here uses **fictional data only**. It spins up a throwaway MariaDB on a private socket and PHP's
|
||||||
|
built-in server, so nothing touches a real database.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
D=$(mktemp -d); cp -r . $D/tinker && cp scripts/demo/router.php scripts/demo/seed.py scripts/demo/capture.py $D/
|
||||||
|
cd $D
|
||||||
|
printf 'DB_HOST=localhost\nDB_USER=root\nDB_PASS=\nDB_NAME=ticketing_system\n' > tinker/.env
|
||||||
|
mariadb-install-db --no-defaults --datadir=$D/db/data --auth-root-authentication-method=normal --skip-test-db
|
||||||
|
mariadbd --no-defaults --datadir=$D/db/data --socket=$D/db/sock --skip-networking --user=root &
|
||||||
|
mariadb --no-defaults -S $D/db/sock -e 'CREATE DATABASE ticketing_system CHARACTER SET utf8mb4'
|
||||||
|
(cd tinker && php -d mysqli.default_socket=$D/db/sock migrations/migrate.php)
|
||||||
|
python3 seed.py && mariadb --no-defaults -S $D/db/sock ticketing_system < seed.sql
|
||||||
|
php -d mysqli.default_socket=$D/db/sock -S 127.0.0.1:8300 -t tinker router.php &
|
||||||
|
python3 capture.py # screenshots + GIF -> docs/img/
|
||||||
|
```
|
||||||
|
|
||||||
|
The app trusts Authelia `Remote-User` headers, so `capture.py` sends them directly. Requires `playwright` and `pillow`.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# flake8: noqa: E501 (demo data and canned output contain long literal lines)
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from PIL import Image
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
OUT = str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "img")
|
||||||
|
B = "http://127.0.0.1:8300"
|
||||||
|
H = {
|
||||||
|
"Remote-User": "alex.rivera",
|
||||||
|
"Remote-Name": "Alex Rivera",
|
||||||
|
"Remote-Email": "alex@example.com",
|
||||||
|
"Remote-Groups": "admin,employee",
|
||||||
|
}
|
||||||
|
P1 = "152847156"
|
||||||
|
CEPH = "524938499"
|
||||||
|
JS_TOP = (
|
||||||
|
"document.activeElement&&document.activeElement.blur();"
|
||||||
|
"document.documentElement.style.scrollBehavior='auto';window.scrollTo(0,0)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ctxmk(p, theme="dark", w=1440, h=900):
|
||||||
|
b = await p.chromium.launch()
|
||||||
|
c = await b.new_context(viewport={"width": w, "height": h}, extra_http_headers=H)
|
||||||
|
await c.add_init_script(f"try{{localStorage.setItem('lt_theme','{theme}')}}catch(e){{}}")
|
||||||
|
|
||||||
|
async def avatar(route):
|
||||||
|
import re
|
||||||
|
|
||||||
|
uid = int((re.search(r"user_id=(\d+)", route.request.url) or [0, 0])[1] or 0)
|
||||||
|
ini = {1: "AR", 2: "SC", 3: "RB", 4: "JK", 5: "HW"}.get(uid, "??")
|
||||||
|
col = {1: "#ff6b00", 2: "#00d4ff", 3: "#b44aff", 4: "#00ff88", 5: "#ffc800"}.get(uid, "#888")
|
||||||
|
svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#050a10"/><text x="32" y="40" font-family="monospace" font-size="24" font-weight="700" fill="{col}" text-anchor="middle">{ini}</text></svg>'
|
||||||
|
await route.fulfill(status=200, content_type="image/svg+xml", body=svg)
|
||||||
|
|
||||||
|
await c.route("**/api/user_avatar.php*", avatar)
|
||||||
|
return b, c
|
||||||
|
|
||||||
|
|
||||||
|
async def go(pg, path, wait=3500):
|
||||||
|
await pg.goto(B + path)
|
||||||
|
await pg.wait_for_timeout(wait)
|
||||||
|
await pg.evaluate(JS_TOP)
|
||||||
|
await pg.wait_for_timeout(300)
|
||||||
|
|
||||||
|
|
||||||
|
async def tab(pg, name):
|
||||||
|
t = pg.get_by_role("tab", name=name).first
|
||||||
|
if await t.count() == 0:
|
||||||
|
t = pg.get_by_text(name, exact=False).first
|
||||||
|
await t.click(timeout=4000)
|
||||||
|
await pg.wait_for_timeout(900)
|
||||||
|
|
||||||
|
|
||||||
|
async def shots():
|
||||||
|
async with async_playwright() as p:
|
||||||
|
b, c = await ctxmk(p)
|
||||||
|
pg = await c.new_page()
|
||||||
|
await go(pg, "/", 5000)
|
||||||
|
await pg.screenshot(path=f"{OUT}/dashboard.png")
|
||||||
|
# table crop
|
||||||
|
await pg.set_viewport_size({"width": 1840, "height": 900})
|
||||||
|
await pg.wait_for_timeout(600)
|
||||||
|
await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})")
|
||||||
|
await pg.wait_for_timeout(500)
|
||||||
|
await pg.screenshot(path=f"{OUT}/ticket-queue.png")
|
||||||
|
await pg.set_viewport_size({"width": 1440, "height": 900})
|
||||||
|
await pg.wait_for_timeout(600)
|
||||||
|
try:
|
||||||
|
await tab(pg, "Kanban")
|
||||||
|
await pg.screenshot(path=f"{OUT}/kanban.png")
|
||||||
|
print("kanban ok")
|
||||||
|
except Exception as e:
|
||||||
|
print("kanban fail", str(e)[:80])
|
||||||
|
await go(pg, "/ticket/" + P1)
|
||||||
|
await pg.screenshot(path=f"{OUT}/ticket-sla.png")
|
||||||
|
await go(pg, "/ticket/" + CEPH)
|
||||||
|
for name, fn in (
|
||||||
|
("Comments", "ticket-comments"),
|
||||||
|
("Dependencies", "ticket-dependencies"),
|
||||||
|
("Activity", "ticket-activity"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await tab(pg, name)
|
||||||
|
await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})")
|
||||||
|
await pg.wait_for_timeout(500)
|
||||||
|
await pg.screenshot(path=f"{OUT}/{fn}.png")
|
||||||
|
print(fn, "ok")
|
||||||
|
except Exception as e:
|
||||||
|
print(fn, "fail", str(e)[:80])
|
||||||
|
await go(pg, "/ticket/create", 3000)
|
||||||
|
await pg.screenshot(path=f"{OUT}/new-ticket.png")
|
||||||
|
await go(pg, "/")
|
||||||
|
await pg.keyboard.press("Control+k")
|
||||||
|
await pg.wait_for_timeout(500)
|
||||||
|
await pg.keyboard.type("ticket", delay=70)
|
||||||
|
await pg.wait_for_timeout(900)
|
||||||
|
await pg.screenshot(path=f"{OUT}/command-palette.png", clip={"x": 320, "y": 60, "width": 800, "height": 460})
|
||||||
|
await pg.keyboard.press("Escape")
|
||||||
|
for path, name in (
|
||||||
|
("/admin/workflow", "admin-workflow"),
|
||||||
|
("/admin/api-keys", "admin-api-keys"),
|
||||||
|
("/admin/audit-log", "admin-audit-log"),
|
||||||
|
("/admin/recurring-tickets", "admin-recurring"),
|
||||||
|
):
|
||||||
|
await go(pg, path, 3500)
|
||||||
|
await pg.screenshot(path=f"{OUT}/{name}.png")
|
||||||
|
print(name, "ok")
|
||||||
|
await b.close()
|
||||||
|
b, c = await ctxmk(p, "light")
|
||||||
|
pg = await c.new_page()
|
||||||
|
await go(pg, "/", 5000)
|
||||||
|
await pg.screenshot(path=f"{OUT}/dashboard-light.png")
|
||||||
|
await b.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def gif():
|
||||||
|
frames = []
|
||||||
|
async with async_playwright() as p:
|
||||||
|
b, c = await ctxmk(p, "dark", 1280, 720)
|
||||||
|
pg = await c.new_page()
|
||||||
|
|
||||||
|
async def snap(n=1, ms=0):
|
||||||
|
if ms:
|
||||||
|
await pg.wait_for_timeout(ms)
|
||||||
|
im = Image.open(io.BytesIO(await pg.screenshot())).convert("RGB").resize((960, 540), Image.LANCZOS)
|
||||||
|
for _ in range(n):
|
||||||
|
frames.append(im)
|
||||||
|
|
||||||
|
await go(pg, "/", 4500)
|
||||||
|
await snap(3)
|
||||||
|
await pg.mouse.wheel(0, 520)
|
||||||
|
await snap(2, 500)
|
||||||
|
try:
|
||||||
|
await tab(pg, "Kanban")
|
||||||
|
await snap(3, 300)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await go(pg, "/ticket/" + P1, 3000)
|
||||||
|
await snap(3)
|
||||||
|
await go(pg, "/ticket/" + CEPH, 3000)
|
||||||
|
await snap(2)
|
||||||
|
for name in ("Comments", "Dependencies", "Activity"):
|
||||||
|
try:
|
||||||
|
await tab(pg, name)
|
||||||
|
await snap(3, 300)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await go(pg, "/", 3000)
|
||||||
|
await pg.keyboard.press("Control+k")
|
||||||
|
await snap(2, 500)
|
||||||
|
for ch in "tick":
|
||||||
|
await pg.keyboard.type(ch)
|
||||||
|
await snap(1, 200)
|
||||||
|
await snap(3, 300)
|
||||||
|
await go(pg, "/admin/workflow", 3000)
|
||||||
|
await snap(3)
|
||||||
|
await b.close()
|
||||||
|
pal = [f.quantize(colors=96, method=Image.MEDIANCUT, dither=Image.NONE) for f in frames]
|
||||||
|
pal[0].save(f"{OUT}/demo.gif", save_all=True, append_images=pal[1:], duration=1100, loop=0, optimize=True)
|
||||||
|
print("gif", len(frames))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
if "gif" not in sys.argv:
|
||||||
|
await shots()
|
||||||
|
if "shots" not in sys.argv:
|
||||||
|
await gif()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router script for PHP's built-in web server, used only for the local screenshot demo.
|
||||||
|
* Serves real static files directly and sends everything else to the application's index.php.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$file = __DIR__ . '/tinker' . $path;
|
||||||
|
|
||||||
|
if ($path !== '/' && is_file($file) && !str_ends_with($file, '.php')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
chdir(__DIR__ . '/tinker');
|
||||||
|
require __DIR__ . '/tinker/index.php';
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
# flake8: noqa: E501 (demo data and canned output contain long literal lines)
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
random.seed(7)
|
||||||
|
|
||||||
|
D = str(pathlib.Path(__file__).resolve().parent)
|
||||||
|
now = datetime.now().replace(microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def q(s):
|
||||||
|
return "'" + str(s).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def ts(h):
|
||||||
|
return q((now - timedelta(hours=h)).strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
|
||||||
|
|
||||||
|
sql = []
|
||||||
|
users = [
|
||||||
|
(1, "alex.rivera", "Alex Rivera", "alex@example.com", "admin,employee", 1),
|
||||||
|
(2, "sam.chen", "Sam Chen", "sam@example.com", "employee", 0),
|
||||||
|
(3, "riley.brooks", "Riley Brooks", "riley@example.com", "employee", 0),
|
||||||
|
(4, "jordan.kim", "Jordan Kim", "jordan@example.com", "employee", 0),
|
||||||
|
(5, "hwmon-daemon", "hwmonDaemon", "", "employee", 0),
|
||||||
|
]
|
||||||
|
for u in users:
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO users(user_id,username,display_name,email,`groups`,is_admin,last_login) VALUES({u[0]},{q(u[1])},{q(u[2])},{q(u[3])},{q(u[4])},{u[5]},{ts(1)});"
|
||||||
|
)
|
||||||
|
tr = [
|
||||||
|
("Open", "In Progress", 0, 0),
|
||||||
|
("Open", "Pending", 0, 0),
|
||||||
|
("Open", "Closed", 1, 0),
|
||||||
|
("Pending", "Open", 0, 0),
|
||||||
|
("Pending", "In Progress", 0, 0),
|
||||||
|
("Pending", "Closed", 1, 0),
|
||||||
|
("In Progress", "Pending", 0, 0),
|
||||||
|
("In Progress", "Open", 0, 0),
|
||||||
|
("In Progress", "Closed", 1, 0),
|
||||||
|
("Closed", "Open", 0, 1),
|
||||||
|
]
|
||||||
|
for a, b, c, d in tr:
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO status_transitions(from_status,to_status,requires_comment,requires_admin) VALUES({q(a)},{q(b)},{c},{d});"
|
||||||
|
)
|
||||||
|
# (title, cat, type, status, prio, assignee, creator, age_h, desc)
|
||||||
|
T = [
|
||||||
|
(
|
||||||
|
"[storage-01][auto][hardware] Drive SN-4F21A8 has SMART issues [single-node][production][problem]",
|
||||||
|
"Hardware",
|
||||||
|
"Problem",
|
||||||
|
"Open",
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
"SMART reports 12 reallocated sectors and 1 pending sector on /dev/sdc.\n\n```\nReallocated_Sector_Ct 12\nCurrent_Pending_Sector 1\n```\nOSD.14 is backed by this drive. Plan a replacement in the next maintenance window.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[node-02][auto][hardware] CPU temperature sustained above 85C [single-node][production][problem]",
|
||||||
|
"Hardware",
|
||||||
|
"Problem",
|
||||||
|
"Open",
|
||||||
|
2,
|
||||||
|
None,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
"15-minute average package temperature 87C (threshold 85C). Fans at 100%. Check airflow and heatsink seating.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[proxmox-cluster][auto][software] Ceph HEALTH_WARN: 3 slow ops on osd.9 [cluster-wide][production][problem]",
|
||||||
|
"Software",
|
||||||
|
"Problem",
|
||||||
|
"In Progress",
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
26,
|
||||||
|
"`ceph health detail` reports slow BlueStore ops on osd.9. PGs remain active+clean. Triage steps in the Ceph runbook.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[node-03][auto][hardware] LXC storage pool above 85% usage [single-node][production][maintenance]",
|
||||||
|
"Hardware",
|
||||||
|
"Maintenance",
|
||||||
|
"Open",
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
9,
|
||||||
|
"Pool plpSSDPool is at 86%. Grow or move guests before it reaches 90%.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[switch-02][auto][network] Link down on uplink port 14 [single-node][production][problem]",
|
||||||
|
"Network",
|
||||||
|
"Problem",
|
||||||
|
"Open",
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
2,
|
||||||
|
"Port 14 (10GbE uplink to node-03) went down 2 hours ago. Traffic failed over to the backup path. Cable or SFP+ suspected.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[proxmox-cluster][manual][software] Upgrade Proxmox VE 8.3 to 8.4 [cluster-wide][production][upgrade]",
|
||||||
|
"Software",
|
||||||
|
"Upgrade",
|
||||||
|
"Pending",
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
70,
|
||||||
|
"Rolling upgrade, one node at a time. Blocked until the Ceph warning on osd.9 is resolved.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[monitor-01][manual][software] Renew TLS certificate for status page [single-node][production][maintenance]",
|
||||||
|
"Software",
|
||||||
|
"Maintenance",
|
||||||
|
"In Progress",
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
30,
|
||||||
|
"Certificate expires in 12 days. Renewal job failed on the DNS challenge.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[proxmox-cluster][manual][security] Rotate API keys used by monitoring agents [cluster-wide][production][task]",
|
||||||
|
"Security",
|
||||||
|
"Task",
|
||||||
|
"Open",
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
48,
|
||||||
|
"Quarterly rotation. Keys: hwmonDaemon, gandalf, pulse workers.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[node-01][auto][hardware] Drive SN-88C0D2 has SMART issues [single-node][production][problem]",
|
||||||
|
"Hardware",
|
||||||
|
"Problem",
|
||||||
|
"Closed",
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
150,
|
||||||
|
"Replaced during maintenance. OSD rebuilt, cluster healthy.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[proxmox-cluster][manual][configuration] Add Prometheus scrape target for new exporter [cluster-wide][production][task]",
|
||||||
|
"General",
|
||||||
|
"Task",
|
||||||
|
"Closed",
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
200,
|
||||||
|
"Added and verified in Targets.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[node-02][manual][hardware] Replace failed NVMe in bay 2 [single-node][production][maintenance]",
|
||||||
|
"Hardware",
|
||||||
|
"Maintenance",
|
||||||
|
"Pending",
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
52,
|
||||||
|
"Replacement ordered. Awaiting delivery.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[storage-01][auto][hardware] Memory ECC correctable errors increasing [single-node][production][problem]",
|
||||||
|
"Hardware",
|
||||||
|
"Problem",
|
||||||
|
"Open",
|
||||||
|
3,
|
||||||
|
None,
|
||||||
|
5,
|
||||||
|
14,
|
||||||
|
"EDAC reports 41 correctable errors on DIMM_B1 in 24 hours.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[proxmox-cluster][manual][software] LXC 134 has no log retention configured [single-node][production][maintenance]",
|
||||||
|
"Software",
|
||||||
|
"Maintenance",
|
||||||
|
"Open",
|
||||||
|
4,
|
||||||
|
None,
|
||||||
|
3,
|
||||||
|
96,
|
||||||
|
"Log volume grows without bound. Add retention and a compactor.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[backup-01][manual][software] Verify weekly offsite backup restore [single-node][production][task]",
|
||||||
|
"General",
|
||||||
|
"Task",
|
||||||
|
"In Progress",
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
40,
|
||||||
|
"Restore one guest from the weekly backup and confirm it boots.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[switch-01][manual][network] Move camera VLAN to dedicated port group [single-node][testing][configuration]",
|
||||||
|
"Network",
|
||||||
|
"Task",
|
||||||
|
"Pending",
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
120,
|
||||||
|
"Needs a maintenance window.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[node-03][auto][hardware] Fan 2 speed below threshold [single-node][production][problem]",
|
||||||
|
"Hardware",
|
||||||
|
"Problem",
|
||||||
|
"Closed",
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
310,
|
||||||
|
"Fan replaced.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[proxmox-cluster][manual][software] Document UPS shutdown order [cluster-wide][production][task]",
|
||||||
|
"General",
|
||||||
|
"Task",
|
||||||
|
"Closed",
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
400,
|
||||||
|
"Documented in the wiki.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[node-02][auto][hardware] Drive SN-1B77E4 temperature above 55C [single-node][production][problem]",
|
||||||
|
"Hardware",
|
||||||
|
"Problem",
|
||||||
|
"Open",
|
||||||
|
4,
|
||||||
|
None,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
"Drive temperature 57C, threshold 55C.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[proxmox-cluster][manual][security] Review SSH access for decommissioned hosts [cluster-wide][production][task]",
|
||||||
|
"Security",
|
||||||
|
"Task",
|
||||||
|
"Pending",
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
88,
|
||||||
|
"Remove stale authorized_keys entries.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[monitor-01][manual][software] Grafana alert rules need review [single-node][production][maintenance]",
|
||||||
|
"Software",
|
||||||
|
"Maintenance",
|
||||||
|
"Open",
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
60,
|
||||||
|
"Several rules have no contact point.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
tids = []
|
||||||
|
for i, t in enumerate(T):
|
||||||
|
tid = str(100000000 + random.randint(1000000, 899999999))
|
||||||
|
tids.append(tid)
|
||||||
|
title, cat, typ, status, pr, asg, cr, age, desc = t
|
||||||
|
closed = ts(max(age - 5, 1)) if status == "Closed" else "NULL"
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO tickets(ticket_id,title,category,type,status,description,created_at,updated_at,closed_at,priority,hash,created_by,updated_by,assigned_to) VALUES({q(tid)},{q(title)},{q(cat)},{q(typ)},{q(status)},{q(desc)},{ts(age)},{ts(max(age-3,1))},{closed},{pr},{q(hashlib.sha256(title.encode()).hexdigest())},{cr},{cr},{asg if asg else 'NULL'});"
|
||||||
|
)
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({cr},'create','ticket',{q(tid)},{q(json.dumps({'title':title}))},{ts(age)});"
|
||||||
|
)
|
||||||
|
if asg:
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES(1,'assign','ticket',{q(tid)},{q(json.dumps({'assigned_to':{'from':'','to':users[asg-1][2]}}))},{ts(max(age-1,1))});"
|
||||||
|
)
|
||||||
|
if status != "Open":
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({asg or 1},'status_change','ticket',{q(tid)},{q(json.dumps({'status':{'from':'Open','to':status}}))},{ts(max(age-2,1))});"
|
||||||
|
)
|
||||||
|
C = {
|
||||||
|
0: [
|
||||||
|
(2, "Confirmed on the node. Pulling the drive from the pool tonight and ordering a replacement.", 2),
|
||||||
|
(1, "Approved. Use the spare from the shelf, then reorder.", 1),
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"Slow ops are isolated to osd.9. `ceph tell osd.9 dump_historic_slow_ops` shows long waits on the DB device.",
|
||||||
|
20,
|
||||||
|
),
|
||||||
|
(2, "Marked the OSD out for now. Recovery is progressing.", 12),
|
||||||
|
],
|
||||||
|
4: [(1, "Failover worked as designed. Swapping the SFP+ module first.", 1)],
|
||||||
|
5: [(2, "Waiting for the Ceph warning to clear before starting the first node.", 30)],
|
||||||
|
6: [(4, "DNS challenge fails because the API token expired. Generated a new one.", 8)],
|
||||||
|
}
|
||||||
|
for idx, cs in C.items():
|
||||||
|
for uid, txt, age in cs:
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO ticket_comments(ticket_id,user_name,comment_text,created_at,markdown_enabled,user_id) VALUES({q(tids[idx])},{q(users[uid-1][2])},{q(txt)},{ts(age)},1,{uid});"
|
||||||
|
)
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({uid},'comment','comment','0',{q(json.dumps({'ticket_id':tids[idx]}))},{ts(age)});"
|
||||||
|
)
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[5])},{q(tids[2])},'blocked_by',1);"
|
||||||
|
)
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[0])},{q(tids[8])},'relates_to',1);"
|
||||||
|
)
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[4])},{q(tids[14])},'relates_to',1);"
|
||||||
|
)
|
||||||
|
for tid in (tids[0], tids[2], tids[4]):
|
||||||
|
sql.append(f"INSERT INTO ticket_watchers(ticket_id,user_id) VALUES({q(tid)},1);")
|
||||||
|
for n, p, s in (
|
||||||
|
("hwmonDaemon (prod)", "hwm_9f3a", "read_write"),
|
||||||
|
("gandalf", "gnd_71c2", "read_write"),
|
||||||
|
("readonly-dashboard", "ro_55de", "read"),
|
||||||
|
):
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO api_keys(key_name,key_hash,key_prefix,scope,created_by,last_used) VALUES({q(n)},{q(hashlib.sha256(p.encode()).hexdigest())},{q(p)},{q(s)},1,{ts(2)});"
|
||||||
|
)
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO recurring_tickets(title_template,description_template,category,type,priority,schedule_type,schedule_day,next_run_at,created_by) VALUES('[proxmox-cluster][auto][software] Monthly patch review [cluster-wide][production][maintenance]','Review pending updates and schedule a window.','Software','Maintenance',4,'monthly',1,{ts(-200)},1);"
|
||||||
|
)
|
||||||
|
sql.append(
|
||||||
|
f"INSERT INTO recurring_tickets(title_template,description_template,category,type,priority,schedule_type,schedule_day,next_run_at,created_by) VALUES('[backup-01][auto][software] Weekly restore test [single-node][production][task]','Restore one guest and verify.','General','Task',4,'weekly',1,{ts(-60)},1);"
|
||||||
|
)
|
||||||
|
open(D + "/seed.sql", "w").write("\n".join(sql))
|
||||||
|
print(len(T), "tickets; ids sample", tids[:3])
|
||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 !== ''
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||